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);