github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/web/play-view.ts (about) 1 // Copyright 2010 Daniel Erat. 2 // All rights reserved. 3 4 import type { AudioWrapper } from './audio-wrapper.js'; 5 import { 6 $, 7 clamp, 8 commonStyles, 9 createShadow, 10 createTemplate, 11 emptyImg, 12 formatDuration, 13 getCoverUrl, 14 getDumpSongUrl, 15 getRatingString, 16 getSongUrl, 17 moveItem, 18 preloadImage, 19 setIcon, 20 smallCoverSize, 21 spinnerIcon, 22 starIcon, 23 updateTitleAttributeForTruncation, 24 wrapString, 25 } from './common.js'; 26 import { getConfig, GainType, Pref } from './config.js'; 27 import { isDialogShown } from './dialog.js'; 28 import type { FullscreenOverlay } from './fullscreen-overlay.js'; 29 import { createMenu, isMenuShown } from './menu.js'; 30 import { showOptionsDialog } from './options-dialog.js'; 31 import { showSongInfoDialog } from './song-info-dialog.js'; 32 import type { SongTable } from './song-table.js'; 33 import { preloadStats, showStatsDialog } from './stats-dialog.js'; 34 import UpdateDialog from './update-dialog.js'; 35 import Updater from './updater.js'; 36 37 const template = createTemplate(` 38 <style> 39 :host { 40 display: flex; 41 flex-direction: column; 42 overflow: hidden; 43 position: relative; /* needed for menu-button */ 44 } 45 46 #menu-button { 47 cursor: pointer; 48 font-size: 32px; 49 padding: var(--margin); 50 padding-bottom: calc(var(--margin) / 2); 51 position: absolute; 52 right: 0; 53 top: -24px; 54 user-select: none; 55 } 56 57 #song-info { 58 display: flex; 59 margin: var(--margin); 60 } 61 62 #cover-div { 63 align-items: center; 64 display: flex; 65 justify-content: center; 66 margin-right: var(--margin); 67 } 68 #cover-div.empty { 69 background-color: var(--cover-missing-color); 70 outline: solid 1px var(--border-color); 71 outline-offset: -1px; 72 } 73 #cover-img { 74 cursor: pointer; 75 height: 70px; 76 object-fit: cover; 77 user-select: none; 78 width: 70px; 79 } 80 #cover-div.empty #cover-img { 81 /* Make fully transparent rather than using visibility: hidden so the 82 * image will still be clickable even if the cover is missing. */ 83 opacity: 0; 84 } 85 86 #spinner { 87 display: none; 88 fill: #fff; 89 filter: drop-shadow(0 0 4px #000); 90 height: 14px; 91 left: 62px; 92 opacity: 0.8; 93 position: absolute; 94 top: 15px; 95 width: 14px; 96 } 97 #spinner.visible { 98 display: block; 99 } 100 101 #rating-overlay { 102 display: flex; 103 filter: drop-shadow(0 0 4px #000); 104 left: calc(var(--margin) + 1px); 105 pointer-events: none; 106 position: absolute; 107 top: calc(var(--margin) + 54px); 108 user-select: none; 109 } 110 #rating-overlay svg { 111 fill: #fff; 112 height: 15px; 113 margin-right: -2px; 114 width: 15px; 115 } 116 117 #details { 118 line-height: 1.3; 119 white-space: nowrap; 120 } 121 #artist { 122 font-weight: bold; 123 } 124 #title { 125 font-style: italic; 126 } 127 #time { 128 opacity: 0.7; 129 /* Add a layout boundary since we update this frequently: 130 * http://blog.wilsonpage.co.uk/introducing-layout-boundaries/ 131 * Oddly, doing this on #details doesn't seem to have the same effect. */ 132 height: 17px; 133 overflow: hidden; 134 width: 100px; 135 } 136 #controls { 137 margin: var(--margin); 138 margin-top: 0; 139 user-select: none; 140 white-space: nowrap; 141 } 142 #controls button { 143 width: 44px; 144 } 145 #controls > *:not(:first-child) { 146 margin-left: var(--button-spacing); 147 } 148 #play-pause svg:first-child { 149 display: none; 150 } 151 #play-pause.playing svg:first-child { 152 display: inline; 153 } 154 #play-pause.playing svg:last-child { 155 display: none; 156 } 157 </style> 158 159 <fullscreen-overlay></fullscreen-overlay> 160 161 <div id="menu-button">…</div> 162 163 <audio-wrapper></audio-wrapper> 164 165 <div id="song-info"> 166 <div id="cover-div"> 167 <img id="cover-img" alt="" /> 168 <svg id="spinner"></svg> 169 <div id="rating-overlay"></div> 170 </div> 171 <div id="details"> 172 <div id="artist"></div> 173 <div id="title"></div> 174 <div id="album"></div> 175 <div id="time"></div> 176 </div> 177 </div> 178 179 <!-- prettier-ignore --> 180 <div id="controls"> 181 <button id="prev" disabled title="Previous song (Alt+P)"> 182 <!-- "icon-step_backward" from MFG Labs --> 183 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1696.9295 2545.2094" width="14" height="14"> 184 <path d="M0 1906V606q0-50 35.5-85.5T121 485h239q50 0 85 35.5t35 85.5v557l1057-655q60-39 102.5-14t42.5 100v1323q0 76-42.5 101t-102.5-15L480 1349v557q0 50-35.5 85.5T360 2027H121q-49 0-85-36t-36-85z"/> 185 </svg> 186 </button> 187 <button id="play-pause" disabled title="Pause (Space)"> 188 <!-- "icon-pause" from MFG Labs --> 189 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1632.1216 2545.2094" width="14" height="14"> 190 <path d="M0 1963q0 55 38 94t93 39h260q55 0 93-39t38-94V547q0-55-38-93t-93-38H131q-55 0-93 38T0 547v1416zm983 0q0 55 38.5 94t92.5 39h261q54 0 92.5-39t38.5-94V547q0-55-38.5-93t-92.5-38h-261q-54 0-92.5 38T983 547v1416z"/> 191 </svg> 192 <!-- "icon-play" from MFG Labs --> 193 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1350 2545.2094" width="14" height="14"> 194 <path d="M0 1950V562q0-79 45.5-105.5T153 472l1156 715q41 29 41 69 0 18-10 35.5t-20 25.5l-11 8-1156 716q-62 41-107.5 14.5T0 1950z"/> 195 </svg> 196 </button> 197 <button id="next" disabled title="Next song (Alt+N)"> 198 <!-- "icon-step_forward" from MFG Labs --> 199 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1795 2545.2094" width="14" height="14"> 200 <path d="M0 1921V531q0-84 43-94.5T174 473l1100 695V531q0-43 36.5-80t79.5-37h232q81 0 127 35t46 82v1390q0 47-46 82t-127 35h-232q-43 0-79.5-37t-36.5-80v-579L174 2038q-88 40-131 7.5T0 1921z"/> 201 </svg> 202 </button> 203 </div> 204 205 <song-table id="playlist"></song-table> 206 `); 207 208 const SEEK_SEC = 10; // seconds to skip when seeking forward or back 209 const NOTIFICATION_SEC = 3; // duration to show song-change notification 210 const PLAY_DELAY_MS = 500; // delay before playing when cycling track 211 const PRELOAD_SEC = 20; // seconds before end of song to load next song 212 const UPDATE_POSITION_SLOP_MS = 10; // time to wait past second boundary 213 const MAX_SONG_URLS = 10; // max entries for |#songUrls| 214 const COVER_TOOLTIP_WIDTH = 50; // max width in chars for cover image tooltip 215 216 // <play-view> plays and displays information about songs. It also maintains 217 // and displays a playlist. Songs can be enqueued by calling enqueueSongs(). 218 // 219 // When new tags are created, a 'newtags' CustomEvent with a 'detail.tags' 220 // property containing a string array of the new tags is emitted. 221 // 222 // When an artist or album field in the playlist is clicked, a 'field' 223 // CustomEvent is emitted. See <song-table> for more details. 224 // 225 // When the current cover art changes due to a song change, a 'cover' 226 // CustomEvent is emitted with a 'detail.url' string property corresponding to a 227 // URL to a |smallCoverSize| WebP image. This property is null if no cover art 228 // is available. 229 export class PlayView extends HTMLElement { 230 #config = getConfig(); 231 #updater: Updater | null = null; // initialized in connectedCallback() 232 #songs: Song[] = []; // playlist 233 #tags: string[] = []; // available tags loaded from server 234 #currentIndex = -1; // index into #songs of current track 235 #startTime: Date | null = null; // time at which current track started playing 236 #reportedCurrentTrack = false; // already reported current as played? 237 #reachedEndOfSongs = false; // did we hit end of last song? 238 #updateDialog: UpdateDialog | null = null; // edit rating and tags 239 #notification: Notification | null = null; // displays song changes 240 #closeNotificationTimeoutId: number | null = null; 241 #playDelayMs = PLAY_DELAY_MS; 242 #playTimeoutId: number | null = null; // for #playInternal() 243 #lastUpdatePosition = 0; // audio position in last #updatePosition() 244 #updatePositionTimeoutId: number | null = null; 245 #autoGainType = GainType.TRACK; // what to use for GainType.AUTO 246 #songUrls = new Map(); // cache of filename -> absolute URL 247 248 #shadow = createShadow(this, template); 249 #overlay = this.#shadow.querySelector( 250 'fullscreen-overlay' 251 ) as FullscreenOverlay; 252 #audio = this.#shadow.querySelector('audio-wrapper') as AudioWrapper; 253 #playlistTable = $('playlist', this.#shadow) as SongTable; 254 255 #coverDiv = $('cover-div', this.#shadow); 256 #coverImage = $('cover-img', this.#shadow) as HTMLImageElement; 257 #spinner = $('spinner', this.#shadow); 258 #ratingOverlay = $('rating-overlay', this.#shadow); 259 #artistDiv = $('artist', this.#shadow); 260 #titleDiv = $('title', this.#shadow); 261 #albumDiv = $('album', this.#shadow); 262 #timeDiv = $('time', this.#shadow); 263 #prevButton = $('prev', this.#shadow) as HTMLButtonElement; 264 #nextButton = $('next', this.#shadow) as HTMLButtonElement; 265 #playPauseButton = $('play-pause', this.#shadow) as HTMLButtonElement; 266 267 constructor() { 268 super(); 269 270 this.#shadow.adoptedStyleSheets = [commonStyles]; 271 272 this.#overlay.addEventListener('next', () => this.#cycleTrack(1)); 273 274 // We're leaking this callback, but it doesn't matter in practice since 275 // play-view never gets removed from the DOM. 276 this.#config.addCallback((name, value) => { 277 if ([Pref.GAIN_TYPE, Pref.PRE_AMP].includes(name)) { 278 this.#updateGain(); 279 } 280 }); 281 282 this.#spinner = setIcon(this.#spinner, spinnerIcon); 283 284 const menuButton = $('menu-button', this.#shadow); 285 menuButton.addEventListener('click', () => { 286 const rect = menuButton.getBoundingClientRect(); 287 createMenu( 288 rect.right + 12, // compensate for right padding 289 rect.bottom, 290 [ 291 { 292 id: 'fullscreen', 293 text: 'Fullscreen', 294 cb: () => (this.#overlay.visible = true), 295 hotkey: 'Alt+V', 296 }, 297 { 298 id: 'options', 299 text: 'Options…', 300 cb: showOptionsDialog, 301 hotkey: 'Alt+O', 302 }, 303 { 304 id: 'stats', 305 text: 'Stats…', 306 cb: showStatsDialog, 307 hotkey: 'Alt+S', 308 }, 309 { 310 id: 'info', 311 text: 'Song info…', 312 cb: () => { 313 const song = this.#currentSong; 314 if (song) showSongInfoDialog(song, true /* isCurrent */); 315 }, 316 hotkey: 'Alt+I', 317 }, 318 { 319 id: 'debug', 320 text: 'Debug…', 321 cb: () => { 322 const song = this.#currentSong; 323 if (song) window.open(getDumpSongUrl(song.songId), '_blank'); 324 }, 325 hotkey: 'Alt+D', 326 }, 327 ], 328 true /* alignRight */ 329 ); 330 }); 331 332 this.#audio.addEventListener('ended', this.#onEnded); 333 this.#audio.addEventListener('pause', this.#onPause); 334 this.#audio.addEventListener('play', this.#onPlay); 335 this.#audio.addEventListener('playing', this.#onPlaying); 336 this.#audio.addEventListener('timeupdate', this.#onTimeUpdate); 337 this.#audio.addEventListener('error', this.#onError); 338 339 this.#coverImage.addEventListener('click', () => this.#showUpdateDialog()); 340 this.#coverImage.addEventListener('load', () => 341 this.#updateMediaSessionMetadata(true /* imageLoaded */) 342 ); 343 344 this.#prevButton.addEventListener('click', () => 345 this.#cycleTrack(-1, true /* delayPlay */) 346 ); 347 this.#nextButton.addEventListener('click', () => 348 this.#cycleTrack(1, true /* delayPlay */) 349 ); 350 this.#playPauseButton.addEventListener('click', () => this.#togglePause()); 351 352 this.#playlistTable.addEventListener('field', ((e: CustomEvent) => { 353 this.dispatchEvent(new CustomEvent('field', { detail: e.detail })); 354 }) as EventListenerOrEventListenerObject); 355 this.#playlistTable.addEventListener('reorder', ((e: CustomEvent) => { 356 this.#currentIndex = moveItem( 357 this.#songs, 358 e.detail.fromIndex, 359 e.detail.toIndex, 360 this.#currentIndex 361 )!; 362 this.#handlePlaylistChange(false); 363 }) as EventListenerOrEventListenerObject); 364 this.#playlistTable.addEventListener('menu', ((e: CustomEvent) => { 365 const idx = e.detail.index; 366 const orig = e.detail.orig; 367 orig.preventDefault(); 368 369 const menu = createMenu(orig.pageX, orig.pageY, [ 370 { 371 id: 'play', 372 text: 'Play', 373 cb: () => this.#selectTrack(idx), 374 }, 375 { 376 id: 'remove', 377 text: 'Remove', 378 cb: () => this.#removeSongs(idx, 1), 379 }, 380 { 381 id: 'truncate', 382 text: 'Truncate', 383 cb: () => this.#removeSongs(idx, this.#songs.length - idx), 384 }, 385 { text: '-' }, 386 { 387 id: 'info', 388 text: 'Info…', 389 cb: () => showSongInfoDialog(this.#songs[idx]), 390 }, 391 { 392 id: 'update', 393 text: 'Rate/tag…', 394 cb: () => this.#showUpdateDialog(this.#songs[idx]), 395 }, 396 { 397 id: 'debug', 398 text: 'Debug…', 399 cb: () => window.open(getDumpSongUrl(e.detail.songId), '_blank'), 400 }, 401 ]); 402 403 // Highlight the row while the menu is open. 404 this.#playlistTable.setRowMenuShown(idx, true); 405 menu.addEventListener('close', () => { 406 this.#playlistTable.setRowMenuShown(idx, false); 407 }); 408 }) as EventListenerOrEventListenerObject); 409 410 this.#handlePlaylistChange(true); 411 412 preloadStats(); 413 } 414 415 connectedCallback() { 416 this.#updater = new Updater(); 417 418 if ('mediaSession' in navigator) { 419 const ms = navigator.mediaSession; 420 ms.setActionHandler('play', () => this.#play()); 421 ms.setActionHandler('pause', () => this.#pause()); 422 ms.setActionHandler('seekbackward', () => this.#seek(-SEEK_SEC)); 423 ms.setActionHandler('seekforward', () => this.#seek(SEEK_SEC)); 424 ms.setActionHandler('previoustrack', () => 425 this.#cycleTrack(-1, true /* delayPlay */) 426 ); 427 ms.setActionHandler('nexttrack', () => 428 this.#cycleTrack(1, true /* delayPlay */) 429 ); 430 } 431 432 document.addEventListener( 433 'visibilitychange', 434 this.#onDocumentVisibilityChange 435 ); 436 document.body.addEventListener('keydown', this.#onKeyDown); 437 window.addEventListener('beforeunload', this.#onBeforeUnload); 438 } 439 440 disconnectedCallback() { 441 this.#updater?.destroy(); 442 this.#updater = null; 443 444 this.#cancelCloseNotificationTimeout(); 445 this.#cancelPlayTimeout(); 446 this.#cancelUpdatePositionTimeout(); 447 448 if ('mediaSession' in navigator) { 449 const ms = navigator.mediaSession; 450 ms.setActionHandler('play', null); 451 ms.setActionHandler('pause', null); 452 ms.setActionHandler('seekbackward', null); 453 ms.setActionHandler('seekforward', null); 454 ms.setActionHandler('previoustrack', null); 455 ms.setActionHandler('nexttrack', null); 456 } 457 458 document.removeEventListener( 459 'visibilitychange', 460 this.#onDocumentVisibilityChange 461 ); 462 document.body.removeEventListener('keydown', this.#onKeyDown); 463 window.removeEventListener('beforeunload', this.#onBeforeUnload); 464 } 465 466 set tags(tags: string[]) { 467 this.#tags = tags; 468 } 469 470 #onDocumentVisibilityChange = () => { 471 // We hold off on updating the displayed time while the document is 472 // hidden, so update it as soon as the document is shown. 473 if (!document.hidden) this.#updatePosition(); 474 }; 475 476 #onKeyDown = (e: KeyboardEvent) => { 477 if (isDialogShown() || isMenuShown()) return; 478 479 if ( 480 (() => { 481 if (e.altKey && e.key === 'd') { 482 const song = this.#currentSong; 483 if (song) window.open(getDumpSongUrl(song.songId), '_blank'); 484 this.#overlay.visible = false; 485 return true; 486 } else if (e.altKey && e.key === 'i') { 487 const song = this.#currentSong; 488 if (song) showSongInfoDialog(song, true /* isCurrent */); 489 this.#overlay.visible = false; 490 return true; 491 } else if (e.altKey && e.key === 'n') { 492 this.#cycleTrack(1, true /* delayPlay */); 493 return true; 494 } else if (e.altKey && e.key === 'o') { 495 showOptionsDialog(); 496 this.#overlay.visible = false; 497 return true; 498 } else if (e.altKey && e.key === 'p') { 499 this.#cycleTrack(-1, true /* delayPlay */); 500 return true; 501 } else if (e.altKey && e.key === 'r') { 502 this.#showUpdateDialog(); 503 this.#updateDialog?.focusRating(); 504 this.#overlay.visible = false; 505 return true; 506 } else if (e.altKey && e.key === 's') { 507 showStatsDialog(); 508 this.#overlay.visible = false; 509 } else if (e.altKey && e.key === 't') { 510 this.#showUpdateDialog(); 511 this.#updateDialog?.focusTags(); 512 this.#overlay.visible = false; 513 return true; 514 } else if (e.altKey && e.key === 'v') { 515 this.#overlay.visible = !this.#overlay.visible; 516 return true; 517 } else if (e.key === ' ') { 518 this.#togglePause(); 519 return true; 520 } else if (e.key === 'Escape' && this.#overlay.visible) { 521 this.#overlay.visible = false; 522 return true; 523 } else if (e.key === 'ArrowLeft') { 524 this.#seek(-SEEK_SEC); 525 return true; 526 } else if (e.key === 'ArrowRight') { 527 this.#seek(SEEK_SEC); 528 return true; 529 } 530 return false; 531 })() 532 ) { 533 e.preventDefault(); 534 e.stopPropagation(); 535 } 536 }; 537 538 #onBeforeUnload = () => { 539 this.#closeNotification(); 540 }; 541 542 get #currentSong() { 543 return this.#songs[this.#currentIndex] ?? null; 544 } 545 get #prevSong() { 546 return this.#songs[this.#currentIndex - 1] ?? null; 547 } 548 get #nextSong() { 549 return this.#songs[this.#currentIndex + 1] ?? null; 550 } 551 552 // Returns the absolute URL corresponding to |filename| just like 553 // getSongUrl() in common.ts, but caches results to make calls cheap. 554 #getSongUrl(filename: string) { 555 const urls = this.#songUrls; 556 let url = urls.get(filename); 557 if (url) return url; 558 559 url = getSongUrl(filename); 560 while (urls.size >= MAX_SONG_URLS) urls.delete(urls.keys().next().value); 561 urls.set(filename, url); 562 return url; 563 } 564 565 resetForTest() { 566 if (this.#songs.length) this.#removeSongs(0, this.#songs.length); 567 this.#updateDialog?.close(false /* save */); 568 } 569 setPlayDelayMsForTest(ms: number) { 570 this.#playDelayMs = ms; 571 } 572 573 // Adds |songs| to the playlist. 574 // If |clearFirst| is true, the existing playlist is cleared first. 575 // If |afterCurrent| is true, |songs| are inserted immediately after the 576 // current song. Otherwise, they are appended to the end of the playlist. 577 enqueueSongs(songs: Song[], clearFirst: boolean, afterCurrent: boolean) { 578 if (clearFirst) this.#removeSongs(0, this.#songs.length); 579 580 let index = afterCurrent 581 ? Math.min(this.#currentIndex + 1, this.#songs.length) 582 : this.#songs.length; 583 songs.forEach((s) => this.#songs.splice(index++, 0, s)); 584 585 this.#playlistTable.setSongs(this.#songs); 586 587 if (this.#currentIndex === -1) this.#selectTrack(0); 588 else if (this.#reachedEndOfSongs) this.#cycleTrack(1); 589 else this.#handlePlaylistChange(false); 590 } 591 592 // Removes |len| songs starting at index |start| from the playlist. 593 #removeSongs(start: number, len: number) { 594 if (start < 0 || len <= 0 || start + len > this.#songs.length) return; 595 596 this.#songs.splice(start, len); 597 this.#playlistTable.setSongs(this.#songs); 598 599 // If the next song is getting dropped, we don't need to preload it. 600 const end = start + len - 1; 601 const next = this.#currentIndex + 1; 602 if (start <= next && end >= next) this.#audio.preloadSrc = ''; 603 604 // If we're keeping the current song, things are pretty simple. 605 if (start > this.#currentIndex || end < this.#currentIndex) { 606 // If we're removing songs before the current one, we need to update the 607 // index and highlighting. 608 if (end < this.#currentIndex) { 609 this.#playlistTable.setRowActive(this.#currentIndex, false); 610 this.#currentIndex -= len; 611 this.#playlistTable.setRowActive(this.#currentIndex, true); 612 } 613 this.#handlePlaylistChange(false); 614 return; 615 } 616 617 // Stop playing the (just-removed) current song and choose a new one. 618 this.#audio.src = null; 619 this.#playlistTable.setRowActive(this.#currentIndex, false); 620 this.#currentIndex = -1; 621 622 // If there are songs after the last-removed one, switch to the first of 623 // them. 624 if (this.#songs.length > start) { 625 this.#selectTrack(start); 626 return; 627 } 628 629 // Otherwise, we truncated the playlist, i.e. we deleted all songs from 630 // the currently-playing one to the end. Jump to the last song. 631 // TODO: Pausing is hokey. It'd probably be better to act as if we'd 632 // actually reached the end of the last song, but that'd probably require 633 // waiting for its duration to be loaded so we can seek. 634 this.#selectTrack(this.#songs.length); 635 this.#pause(); 636 } 637 638 // Plays the song at |offset| in the playlist relative to the current song. 639 // If |delayPlay| is true, waits a bit before actually playing the audio 640 // (in case the user is going to cycle the track again). 641 #cycleTrack(offset: number, delayPlay = false) { 642 this.#selectTrack(this.#currentIndex + offset, delayPlay); 643 } 644 645 // Plays the song at |index| in the playlist. 646 // If |delayPlay| is true, waits a bit before actually playing the audio 647 // (in case the user might be about to select a different track). 648 #selectTrack(index: number, delayPlay = false) { 649 if (!this.#songs.length) { 650 this.#currentIndex = -1; 651 this.#handlePlaylistChange(true); 652 this.#reachedEndOfSongs = false; 653 return; 654 } 655 656 index = clamp(index, 0, this.#songs.length - 1); 657 if (index === this.#currentIndex) return; 658 659 this.#playlistTable.setRowActive(this.#currentIndex, false); 660 this.#playlistTable.setRowActive(index, true); 661 this.#playlistTable.scrollToRow(index); 662 this.#currentIndex = index; 663 this.#audio.src = null; 664 665 this.#handlePlaylistChange(true); 666 this.#play(delayPlay); 667 668 if (document.hidden) this.#showNotification(); 669 } 670 671 // Updates the view in response to a playlist change. 672 // |currentChanged| indicates whether |#currentSong| also changed. 673 #handlePlaylistChange(currentChanged: boolean) { 674 if (currentChanged) this.#updateSongDisplay(); 675 676 this.#prevButton.disabled = !this.#prevSong; 677 this.#nextButton.disabled = !this.#nextSong; 678 this.#playPauseButton.disabled = !this.#currentSong; 679 680 this.#overlay.updateSongs(this.#currentSong, this.#nextSong); 681 682 // Make the "auto" gain type use album-specific gain adjustments if the 683 // previous or next song is from the same album as the current song: 684 // https://github.com/derat/nup/issues/54 685 const albumId = this.#currentSong?.albumId ?? ''; 686 this.#autoGainType = 687 albumId !== '' && 688 (this.#prevSong?.albumId === albumId || 689 this.#nextSong?.albumId === albumId) 690 ? GainType.ALBUM 691 : GainType.TRACK; 692 693 // TODO: Preload the next song if needed. 694 } 695 696 #updateSongDisplay() { 697 const song = this.#currentSong; 698 document.title = song ? `${song.artist} - ${song.title}` : 'nup'; 699 700 this.#artistDiv.innerText = song ? song.artist : ''; 701 this.#titleDiv.innerText = song ? song.title : ''; 702 this.#albumDiv.innerText = song ? song.album : ''; 703 this.#timeDiv.innerText = ''; 704 705 updateTitleAttributeForTruncation(this.#artistDiv, song ? song.artist : ''); 706 updateTitleAttributeForTruncation(this.#titleDiv, song ? song.title : ''); 707 updateTitleAttributeForTruncation(this.#albumDiv, song ? song.album : ''); 708 709 if (song && song.coverFilename) { 710 const url = getCoverUrl(song.coverFilename, smallCoverSize); 711 this.#coverImage.src = url; 712 this.#coverDiv.classList.remove('empty'); 713 this.dispatchEvent(new CustomEvent('cover', { detail: { url } })); 714 } else { 715 this.#coverImage.src = emptyImg; 716 this.#coverDiv.classList.add('empty'); 717 this.dispatchEvent(new CustomEvent('cover', { detail: { url: null } })); 718 } 719 720 // Cache the scaled cover images for the next song and the one after it. 721 // This prevents ugly laggy updates here and in <fullscreen-overlay>. 722 // Note that this will probably only work for non-admin users due to an 723 // App Engine "feature": https://github.com/derat/nup/issues/1 724 const precacheCover = (s?: Song) => { 725 if (!s?.coverFilename) return; 726 preloadImage(getCoverUrl(s.coverFilename, smallCoverSize)); 727 }; 728 precacheCover(this.#songs[this.#currentIndex + 1]); 729 precacheCover(this.#songs[this.#currentIndex + 2]); 730 731 this.#updateCoverTitleAttribute(); 732 this.#updateRatingOverlay(); 733 // Metadata will be updated again after |#coverImage| is loaded. 734 this.#updateMediaSessionMetadata(false /* imageLoaded */); 735 } 736 737 #updateCoverTitleAttribute() { 738 const song = this.#currentSong; 739 if (!song) { 740 this.#coverImage.title = ''; 741 return; 742 } 743 744 this.#coverImage.title = 745 (song.rating ? 'Rating: ' : '') + 746 getRatingString(song.rating) + 747 '\n' + 748 (song.tags.length 749 ? wrapString('Tags: ' + song.tags.sort().join(' '), COVER_TOOLTIP_WIDTH) 750 : '(Alt+R or Alt+T to edit)'); 751 } 752 753 #updateRatingOverlay() { 754 const stars = this.#currentSong?.rating ?? 0; 755 const overlay = this.#ratingOverlay; 756 while (overlay.children.length > stars) { 757 overlay.removeChild(overlay.lastChild!); 758 } 759 while (this.#ratingOverlay.children.length < stars) { 760 overlay.appendChild(starIcon.content.firstElementChild!.cloneNode(true)); 761 } 762 } 763 764 #updateMediaSessionMetadata(imageLoaded: boolean) { 765 if (!('mediaSession' in navigator)) return; 766 767 const song = this.#currentSong; 768 if (!song) { 769 navigator.mediaSession.metadata = null; 770 return; 771 } 772 773 const artwork: MediaImage[] = []; 774 if (imageLoaded) { 775 const img = this.#coverImage; 776 artwork.push({ 777 src: img.src, 778 sizes: `${img.naturalWidth}x${img.naturalHeight}`, 779 type: 'image/webp', 780 }); 781 } 782 navigator.mediaSession.metadata = new MediaMetadata({ 783 title: song.title, 784 artist: song.artist, 785 album: song.album, 786 artwork, 787 }); 788 } 789 790 #showNotification() { 791 if (!('Notification' in window)) return; 792 793 if (Notification.permission !== 'granted') { 794 if (Notification.permission !== 'denied') { 795 Notification.requestPermission(); 796 } 797 return; 798 } 799 800 this.#closeNotification(); 801 this.#cancelCloseNotificationTimeout(); 802 803 const song = this.#currentSong; 804 if (!song) return; 805 806 const options: NotificationOptions = { 807 body: `${song.title}\n${song.album}\n${formatDuration(song.length)}`, 808 }; 809 if (song.coverFilename) { 810 options.icon = getCoverUrl(song.coverFilename, smallCoverSize); 811 } 812 this.#notification = new Notification(`${song.artist}`, options); 813 this.#closeNotificationTimeoutId = window.setTimeout(() => { 814 this.#closeNotificationTimeoutId = null; 815 this.#closeNotification(); 816 }, NOTIFICATION_SEC * 1000); 817 } 818 819 #closeNotification() { 820 this.#notification?.close(); 821 this.#notification = null; 822 } 823 824 #cancelCloseNotificationTimeout() { 825 if (this.#closeNotificationTimeoutId === null) return; 826 window.clearTimeout(this.#closeNotificationTimeoutId); 827 this.#closeNotificationTimeoutId = null; 828 } 829 830 // Starts playback. If |#currentSong| isn't being played, switches to it 831 // even if we were already playing. Also restarts playback if we were 832 // stopped at the end of the last song in the playlist. 833 // 834 // If |delay| is true, waits a bit before loading media and playing; 835 // otherwise starts playing immediately. 836 #play(delay = false) { 837 if (!this.#currentSong) return; 838 839 this.#cancelPlayTimeout(); 840 this.#showSpinner(); // hidden in #onPlaying and #onError 841 842 if (delay) { 843 console.log(`Playing in ${this.#playDelayMs} ms`); 844 this.#playTimeoutId = window.setTimeout(() => { 845 this.#playTimeoutId = null; 846 this.#playInternal(); 847 }, this.#playDelayMs); 848 } else { 849 this.#playInternal(); 850 } 851 } 852 853 #cancelPlayTimeout() { 854 if (this.#playTimeoutId === null) return; 855 window.clearTimeout(this.#playTimeoutId); 856 this.#playTimeoutId = null; 857 } 858 859 // Internal method called by #play(). 860 #playInternal() { 861 const song = this.#currentSong; 862 if (!song) { 863 this.#hideSpinner(); 864 return; 865 } 866 867 // Get an absolute URL since that's what we'll get from the <audio> 868 // element: https://stackoverflow.com/a/44547904 869 const url = this.#getSongUrl(song.filename); 870 if (this.#audio.src !== url || this.#reachedEndOfSongs) { 871 console.log(`Starting ${song.songId} (${url})`); 872 this.#audio.src = url; 873 this.#audio.currentTime = 0; 874 875 this.#startTime = new Date(); 876 this.#reportedCurrentTrack = false; 877 this.#reachedEndOfSongs = false; 878 this.#lastUpdatePosition = 0; 879 this.#updateGain(); 880 } 881 882 console.log('Playing'); 883 this.#audio.play().catch((e) => { 884 // play() actually returns a promise that is resolved after playback 885 // actually starts. If we change the <audio>'s src or call its pause() 886 // method while in the preparatory state, it complains. Ignore those 887 // errors. 888 // https://developers.google.com/web/updates/2017/06/play-request-was-interrupted 889 if ( 890 e.name === 'AbortError' && 891 (e.message.match(/interrupted by a new load request/) || 892 e.message.match(/interrupted by a call to pause/)) 893 ) { 894 return; 895 } 896 throw e; 897 }); 898 } 899 900 // Pauses playback. Safe to call if already paused or stopped. 901 #pause() { 902 console.log('Pausing'); 903 this.#audio.pause(); 904 } 905 906 #togglePause() { 907 this.#audio.paused ? this.#play() : this.#pause(); 908 } 909 910 #seek(seconds: number) { 911 if (!this.#audio.seekable) return; 912 const newTime = Math.max(this.#audio.currentTime + seconds, 0); 913 if (newTime >= this.#audio.duration) return; 914 this.#audio.currentTime = newTime; 915 this.#updatePosition(); 916 } 917 918 #onEnded = () => { 919 this.#updatePosition(); 920 if (this.#currentIndex >= this.#songs.length - 1) { 921 this.#reachedEndOfSongs = true; 922 } else { 923 this.#cycleTrack(1); 924 } 925 }; 926 927 #onPause = () => { 928 this.#updatePosition(); 929 this.#playPauseButton.classList.remove('playing'); 930 this.#playPauseButton.title = 'Play (Space)'; 931 }; 932 933 #onPlay = () => { 934 this.#updatePosition(); 935 this.#playPauseButton.classList.add('playing'); 936 this.#playPauseButton.title = 'Pause (Space)'; 937 }; 938 939 #onPlaying = () => { 940 this.#hideSpinner(); 941 }; 942 943 #onTimeUpdate = () => { 944 // I was hoping I could just call #scheduleUpdatePosition() here, but it 945 // causes occasional failures in TestDisplayTimeWhilePlaying where it looks 946 // like seconds are being skipped sometimes. 947 this.#updatePosition(); 948 }; 949 950 #onError = () => { 951 this.#hideSpinner(); 952 this.#cycleTrack(1); 953 }; 954 955 #updatePosition() { 956 const song = this.#currentSong; 957 if (song === null) return; 958 959 const pos = this.#audio.currentTime; 960 const played = this.#audio.playtime; 961 const dur = song.length; 962 963 if (!this.#reportedCurrentTrack && (played >= 240 || played > dur / 2)) { 964 this.#updater?.reportPlay(song.songId, this.#startTime!); 965 this.#reportedCurrentTrack = true; 966 } 967 968 this.#overlay.updatePosition(pos); 969 970 // Only update the displayed time while the document is visible. 971 if (!document.hidden) { 972 const str = dur ? `${formatDuration(pos)} / ${formatDuration(dur)}` : ''; 973 if (this.#timeDiv.innerText !== str) this.#timeDiv.innerText = str; 974 } 975 976 // Preload the next song once we're nearing the end of this one. 977 if (pos >= dur - PRELOAD_SEC && this.#nextSong) { 978 const url = this.#getSongUrl(this.#nextSong.filename); 979 if (this.#audio.preloadSrc !== url) { 980 console.log(`Preloading ${this.#nextSong.songId} (${url})`); 981 this.#audio.preloadSrc = url; 982 } 983 } 984 985 // Only schedule the next update when we're actually making progress. 986 if (pos > this.#lastUpdatePosition) this.#scheduleUpdatePosition(); 987 else this.#cancelUpdatePositionTimeout(); 988 this.#lastUpdatePosition = pos; 989 } 990 991 #scheduleUpdatePosition() { 992 this.#cancelUpdatePositionTimeout(); 993 994 if (this.#audio.paused) return; 995 996 // Schedule the next update for just after when we expect the playback 997 // position to cross the next second boundary. 998 const pos = this.#audio.currentTime; 999 const nextMs = 1000 * (Math.floor(pos + 1) - pos); 1000 this.#updatePositionTimeoutId = window.setTimeout(() => { 1001 this.#updatePositionTimeoutId = null; 1002 this.#updatePosition(); 1003 }, nextMs + UPDATE_POSITION_SLOP_MS); 1004 } 1005 1006 #cancelUpdatePositionTimeout() { 1007 if (this.#updatePositionTimeoutId === null) return; 1008 window.clearTimeout(this.#updatePositionTimeoutId); 1009 this.#updatePositionTimeoutId = null; 1010 } 1011 1012 #showSpinner = () => this.#spinner.classList.add('visible'); 1013 #hideSpinner = () => this.#spinner.classList.remove('visible'); 1014 1015 #showUpdateDialog(song: Song | null = null) { 1016 song ??= this.#currentSong; 1017 if (this.#updateDialog || !song) return; 1018 this.#updateDialog = new UpdateDialog( 1019 song, 1020 this.#tags, 1021 (song, rating, tags) => { 1022 this.#updateDialog = null; 1023 1024 if (rating === null && tags === null) return; 1025 1026 this.#updater?.rateAndTag(song.songId, rating, tags); 1027 1028 const isCurrent = song === this.#currentSong; 1029 1030 if (rating !== null) { 1031 song.rating = rating; 1032 if (isCurrent) this.#updateRatingOverlay(); 1033 } 1034 if (tags !== null) { 1035 song.tags = tags; 1036 const created = tags.filter((t) => !this.#tags.includes(t)); 1037 if (created.length > 0) { 1038 this.dispatchEvent( 1039 new CustomEvent('newtags', { detail: { tags: created } }) 1040 ); 1041 } 1042 } 1043 if (isCurrent) this.#updateCoverTitleAttribute(); 1044 } 1045 ); 1046 } 1047 1048 // Adjusts |audio|'s gain appropriately for the current song and settings. 1049 // This implements the approach described at 1050 // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_specification. 1051 #updateGain() { 1052 let adj = this.#config.get(Pref.PRE_AMP); // decibels 1053 1054 let reason = ''; 1055 const song = this.#currentSong; 1056 if (song) { 1057 let gainType = this.#config.get(Pref.GAIN_TYPE); 1058 if (gainType === GainType.AUTO) gainType = this.#autoGainType; 1059 1060 if (gainType === GainType.ALBUM) { 1061 adj += song.albumGain ?? 0; 1062 reason = ' for album'; 1063 } else if (gainType === GainType.TRACK) { 1064 adj += song.trackGain ?? 0; 1065 reason = ' for track'; 1066 } 1067 } 1068 1069 let scale = 10 ** (adj / 20); 1070 1071 // TODO: Add an option to prevent clipping instead of always doing this? 1072 if (song?.peakAmp) scale = Math.min(scale, 1 / song.peakAmp); 1073 1074 console.log(`Scaling amplitude by ${scale.toFixed(3)}${reason}`); 1075 this.#audio.gain = scale; 1076 } 1077 } 1078 1079 customElements.define('play-view', PlayView);