Files
client/docs/music.md
2026-04-17 16:52:51 -04:00

19 KiB

Music Player — Queue System Documentation

Overview

The music page (www/music.html) uses a single unified queue (musicQueue[]) as the source of truth for all playback. The audio engine (SimplePlayer) is a pure playback component — it plays whatever stream URL it receives and fires callbacks when tracks end.


Architecture

flowchart TB
    subgraph Page Layer - musicQueue is the single source of truth
        MQ[musicQueue array]
        QI[queueCurrentIndex]
        QP[Queue Panel UI]
        Logic[Queue Logic: next / prev / insert / remove / reorder]
    end

    subgraph SimplePlayer - pure audio engine
        Play[playTrack - resolve stream and play]
        Toggle[togglePlayPause]
        OnEnd[onEnded callback]
    end

    subgraph Entry Points
        SearchClick[Click track in search results]
        PlusQ[+Q button on track]
        PlusA[+A button on track row]
        AlbumQ[+Q on album card]
        AlbumCover[Click album cover in detail]
        PlaylistPlay[Playlist Play button]
        PlaylistQ[Playlist +Q button]
        QueueClick[Click queue item]
        PlayQueueBtn[Play Queue button]
        PrevNext[Prev / Next buttons]
    end

    SearchClick -->|insert at current+1 and play| Logic
    PlusQ -->|append to end| Logic
    PlusA -->|append album tracks to end| Logic
    AlbumQ -->|fetch album, append to end| Logic
    AlbumCover -->|prepend to front, play from 0| Logic
    PlaylistPlay -->|replace queue, play from 0| Logic
    PlaylistQ -->|append to end| Logic
    QueueClick -->|set index, play| Logic
    PlayQueueBtn -->|play from current or 0| Logic
    PrevNext -->|advance/retreat index, play| Logic

    Logic --> MQ
    Logic --> QI
    MQ --> QP
    QI --> QP

    Logic -->|track at queueCurrentIndex| Play
    OnEnd -->|advance queueCurrentIndex| Logic

Data Structures

Queue State (Page Layer)

musicQueue[]          // Array of normalized track objects — THE source of truth
queueCurrentIndex     // Index of the currently playing track (-1 if nothing playing)

Persisted to localStorage under key music-queue:{pubkey}:

{
  "currentIndex": 2,
  "items": [
    { "id": 123, "title": "...", "artist": "...", "duration": 240, "cover": "...", "albumTitle": "..." }
  ]
}

SimplePlayer (Audio Engine)

SimplePlayer.audio          // HTML audio element
SimplePlayer.dashPlayer     // dash.js MediaPlayer instance
SimplePlayer.resolveStreamFn // Function: track → { streamUrl, isDash }
SimplePlayer.onEnded        // Callback: fired when current track finishes
SimplePlayer.onTrackChanged // Callback: fired when a new track starts playing

The player has no queue and no index. It plays one track at a time.


Play Entry Points

1. Click a Track in Search Results

Behavior: Insert at queueCurrentIndex + 1 and play immediately.

Before: [A, B*, C, D]     (* = currently playing)
Click track X from search
After:  [A, B, X*, C, D]  (X inserted after B, now playing)
  • The clicked track appears in the queue panel
  • The rest of the queue is preserved
  • If queue is empty, the track becomes the only item at index 0

2. +Q Button on a Track Row

Behavior: Append to end of queue. No playback change.

Before: [A, B*, C]
+Q track X
After:  [A, B*, C, X]

3. +A Button on a Track Row (Queue Album from Search Results)

Behavior: Find all tracks in current results sharing the same albumTitle. Append them to end of queue. No playback change.

4. +Q Button on an Album Card

Behavior: Fetch album from API. Append all album tracks to end of queue. No playback change.

5. Click Album Cover Image in Album Detail View

Behavior: Prepend album tracks to front of queue. Play from index 0.

Before: [A, B*, C]
Click album cover (tracks: X, Y, Z)
After:  [X*, Y, Z, A, B, C]

6. Play Queue Button

Behavior: Play from queueCurrentIndex (or 0 if index is -1).

7. Click a Queue Item

Behavior: Set queueCurrentIndex to clicked index. Play that track.

8. Playlist ▶ Play Button

Behavior: Replace entire queue with playlist tracks. Play from index 0.

Before: [A, B*, C]
Play playlist (tracks: X, Y, Z)
After:  [X*, Y, Z]

9. Playlist +Q Button

Behavior: Append playlist tracks to end of queue. No playback change.

10. Prev / Next Buttons

Behavior:

  • Next: queueCurrentIndex = (queueCurrentIndex + 1) % musicQueue.length. Play new track.
  • Prev: queueCurrentIndex = (queueCurrentIndex - 1 + musicQueue.length) % musicQueue.length. Play new track.
  • If queue is empty, do nothing.

11. Track Ends (Auto-Advance)

Behavior: Same as Next button — advance queueCurrentIndex by 1, wrapping around. Play next track.

12. Play / Pause Button

Behavior: Toggle audio.pause() / audio.play(). No queue interaction.


Queue Panel UI

Visual Layout

┌─────────────────────┐
│ QUEUE               │
│ [Play Queue] [Clear]│
├─────────────────────┤
│ ♪ Track A           │
│ ♪ Track B  (active) │  ← queueCurrentIndex, accent border
│─────────────────────│  ← accent divider line (afterCurrent)
│ ♪ Track C           │
│ ♪ Track D           │
└─────────────────────┘
  • Active track: border-color: accent-color
  • Item after active: top border divider (afterCurrent class)
  • Items are drag-reorderable
  • Each item has a ✕ remove button

SimplePlayer API (After Refactor)

Method Description
playTrack(track, resolver?) Resolve stream URL for track and play it
playStream(streamUrl, isDash) Play a raw stream URL
togglePlayPause() Toggle audio pause/play
setResolver(fn) Set default stream resolver function
onEnded Callback: track finished playing
onTrackChanged Callback: new track started
onTimeUpdate Callback: playback position changed

Removed from SimplePlayer:

  • queue[] — lives in musicQueue[] on page
  • currentIndex — lives in queueCurrentIndex on page
  • setQueue() — no longer needed
  • playNext() — page layer handles this
  • playPrev() — page layer handles this
  • getCurrentTrack() — page reads musicQueue[queueCurrentIndex]
  • playCurrent() — replaced by playTrack(track)

Queue Logic Functions (Page Layer)

Function Description
playQueueAt(index) Set queueCurrentIndex, resolve stream, play via SimplePlayer
playNext() Advance index, play
playPrev() Retreat index, play
insertAndPlay(track) Insert at queueCurrentIndex + 1, set index, play
addTracksToQueue(tracks, opts) Append tracks; optionally replace queue and/or play now
addTrackToQueue(track) Append single track to end
prependTracksAndPlay(tracks) Prepend to front, play from 0
queuePlaylist(playlist, opts) Queue or replace-and-play a playlist
queueAlbumFromTrack(track) Queue tracks sharing same albumTitle from current results
queueAlbumById(albumId) Fetch album by ID and append its tracks
removeQueueIndex(index) Remove track from queue
applyQueueReorder() Apply drag-and-drop reorder
clearQueue() Empty the queue
renderQueue() Re-render queue panel HTML
saveQueueLocal() Persist queue to localStorage
loadQueueLocal() Load queue from localStorage

Shareable URL Routing Plan

Current State

The music page has no URL routing. All view state is ephemeral — refreshing the page loses the current search, drill-down, and results context. The only persistent state is the queue, saved to localStorage.

Goal

Update the browser URL as the user navigates so that:

  • URLs are shareable — sending someone a link opens the same album, artist, or search
  • Browser back/forward buttons work naturally
  • Page refresh restores the current view
  • The URL is human-readable

URL Scheme

Use hash-based routing (#/path) to avoid server-side configuration. The music page lives at music.html, so all routes are fragments after that.

View URL Pattern Example
Home / empty music.html or music.html#/ music.html
Search results music.html#/search/{query} music.html#/search/radiohead
Album detail music.html#/album/{albumId} music.html#/album/12345678
Artist detail music.html#/artist/{artistId} music.html#/artist/87654321
Track detail music.html#/track/{trackId} music.html#/track/99887766
My playlist music.html#/playlist/{identifier} music.html#/playlist/my-chill-mix-1709
Friend playlist music.html#/playlist/{pubkey}/{identifier} music.html#/playlist/ab12cd34.../jazz-vibes

Route Flow

flowchart TB
    PageLoad[Page loads] --> ParseHash[parseRoute from hash]
    ParseHash --> RouteSwitch{Route type?}

    RouteSwitch -->|empty or /| HomeView[Show empty search view]
    RouteSwitch -->|/search/query| SearchView[Run search, show unified results]
    RouteSwitch -->|/album/id| AlbumView[Fetch album, show album detail]
    RouteSwitch -->|/artist/id| ArtistView[Fetch artist, show artist detail]
    RouteSwitch -->|/track/id| TrackView[Insert track into queue and play]
    RouteSwitch -->|/playlist/id| PlaylistView[Load playlist, show tracks]

    UserAction[User clicks album/artist/searches] --> UpdateHash[Update hash via navigate]
    UpdateHash --> HashChange[hashchange event fires]
    HashChange --> ParseHash

    BackButton[Browser back] --> HashChange

Implementation Details

1. Route Parser

function parseRoute() {
    const hash = window.location.hash || '';
    const clean = hash.startsWith('#') ? hash.slice(1) : hash;
    if (!clean || clean === '/') return { page: 'home', id: '' };
    const parts = clean.split('/').filter(Boolean);
    return {
        page: parts[0] || 'home',
        id: decodeURIComponent(parts.slice(1).join('/')),
    };
}

2. Navigation Helper

function navigate(path) {
    const finalPath = path.startsWith('/') ? path : '/' + path;
    window.location.hash = '#' + finalPath;
}

This triggers the hashchange event, which calls the route handler.

3. Route Handler

async function handleRoute() {
    const { page, id } = parseRoute();

    if (page === 'search' && id) {
        musicEls.input.value = id;
        await runMusicSearch(id);
    } else if (page === 'album' && id) {
        await drillDownToAlbum(id);
    } else if (page === 'artist' && id) {
        await drillDownToArtist(id);
    } else if (page === 'track' && id) {
        // Insert track into queue and play
        // Requires fetching track metadata first
    } else if (page === 'playlist' && id) {
        // Load playlist by identifier or pubkey:identifier
    } else {
        // Home view — show empty search
    }
}

4. Update Points — Where to Call navigate()

Action Navigate Call
User submits search navigate('/search/' + encodeURIComponent(query))
User clicks album card navigate('/album/' + albumId)
User clicks artist card navigate('/artist/' + artistId)
User clicks track in search results No URL change — this is a play action, not a navigation
User clicks my playlist navigate('/playlist/' + identifier)
User clicks friend playlist navigate('/playlist/' + pubkey + '/' + identifier)
User clicks back button window.history.back() — triggers hashchange

5. Initialization

On page load, after initMusicFeature() completes:

window.addEventListener('hashchange', handleRoute);
await handleRoute(); // Handle initial URL

6. Back Button Integration

The current musicViewStack[] and navigateBack() system should be replaced by browser history. Each navigate() call pushes a new hash entry. The browser back button pops it. The hashchange listener re-renders the appropriate view.

This means:

  • Remove musicViewStack[] and pushCurrentView()
  • Remove the custom ← Back button (browser back handles it)
  • Or keep the ← Back button but wire it to window.history.back()

What Becomes Shareable

Entity Shareable? Notes
Album Yes #/album/12345 — fetches album from API on load
Artist Yes #/artist/67890 — fetches artist from API on load
Search Yes #/search/radiohead — re-runs search on load
Track Yes #/track/11111 — fetches track, inserts into queue, plays
My playlist ⚠️ Partial #/playlist/my-mix — only works if playlist is in localStorage
Published playlist Yes #/playlist/pubkey/identifier — fetches from Nostr relays
Queue state No Queue is local-only, not in URL

Implementation Steps

  1. Add parseRoute() and navigate() helper functions
  2. Add handleRoute() async function that dispatches to the correct view
  3. Wire hashchange listener and initial route handling in initMusicFeature()
  4. Update runMusicSearch() to call navigate('/search/...') instead of just rendering
  5. Update drillDownToAlbum() to call navigate('/album/...') instead of direct render
  6. Update drillDownToArtist() to call navigate('/artist/...') instead of direct render
  7. Update playlist click handlers to call navigate('/playlist/...')
  8. Replace musicViewStack back navigation with window.history.back()
  9. Add track route handler that fetches metadata and inserts into queue
  10. Add playlist route handler that loads from localStorage or fetches from Nostr

Queue-Centric Playback

Philosophy

The music page is a play-from-queue-only app. Nothing plays directly — every play action inserts into the queue first, then plays from the queue. The queue is the single visual and logical source of truth for what is playing and what comes next.

Final Behavior

Action Behavior
Click track in search/artist/album results Insert at top of queue (index 0), then play index 0
#/track/{id} route Insert resolved track at top of queue (index 0), then play index 0
Click album cover in album detail Prepend album tracks to queue, then play index 0
Playlist ▶ button Prepend playlist tracks to queue, then play index 0
+Q on track Append to end of queue, no playback change
+Q on album Append album tracks to end of queue, no playback change
Playlist +Q button Append playlist tracks to end of queue, no playback change

All audible playback starts via queue state (musicQueue[] + queueCurrentIndex) and not from detached direct-play state.

Layout Change: Player Controls Move to Queue Panel

Currently the player bar (cover art, prev/play/next, progress) lives at the bottom of #musicMain. Since the queue is the center of playback, the player controls should live at the bottom of #musicQueuePanel instead.

Before

┌──────────┐ ┌──────────────────────┐ ┌──────────────┐
│ Playlists│ │ Search + Results      │ │ QUEUE        │
│          │ │                       │ │  track A     │
│          │ │                       │ │  track B *   │
│          │ │                       │ │  track C     │
│          │ ├───────────────────────┤ │              │
│          │ │ ◀ ▶ ▶▶  ━━━━━━━━━━━  │ │ Play  Clear  │
└──────────┘ └───────────────────────┘ └──────────────┘

After

┌──────────┐ ┌──────────────────────┐ ┌──────────────┐
│ Playlists│ │ Search + Results      │ │ QUEUE        │
│          │ │                       │ │  track A     │
│          │ │                       │ │  track B *   │
│          │ │                       │ │  track C     │
│          │ │                       │ ├──────────────┤
│          │ │                       │ │ 🔁 🔀        │
│          │ │                       │ │ ◀ ▶ ▶▶      │
│          │ │                       │ │ ━━━━━━━━━━━  │
│          │ │                       │ │ 0:42 / 3:21  │
└──────────┘ └───────────────────────┘ └──────────────┘

The player bar is removed from #musicMain and its contents are placed at the bottom of #musicQueuePanel, below the queue list. The queue panel uses flex-direction: column with the queue list taking flex: 1; overflow: auto and the player controls pinned at the bottom with flex-shrink: 0.

Playback Modes

playbackMode has four values:

Mode Icon Behavior on Track End
normal Advance to next track. Stop after last track.
loop-track 🔂 Replay the same track from the beginning.
loop-queue 🔁 Advance to next track. Wrap from last to first.
shuffle 🔀 Pick a random track from the queue (not the current one).

State

let playbackMode = 'normal';  // 'normal' | 'loop-track' | 'loop-queue' | 'shuffle'

Persisted to localStorage with queue payload (music-queue:{pubkey}) as:

{
  "currentIndex": 3,
  "playbackMode": "shuffle",
  "items": [ ... ]
}

Toggle Behavior

A single mode button cycles on click:

normal → loop-queue → loop-track → shuffle → normal

The button label/icon updates to reflect the current mode.

onEnded Logic

On track end (musicPlayer.onEnded):

  • loop-track: replay current index
  • shuffle: play random index (prefer different from current when possible)
  • loop-queue: play next index with wraparound
  • normal: play next index without wraparound; stop at queue end

UI Buttons

Mode button UI:

<button id="musicPlaybackModeBtn" class="btn musicControlBtn" type="button" title="Playback mode"></button>

Label mapping:

  • normal
  • loop-queue🔁
  • loop-track🔂
  • shuffle🔀

Queue Panel Layout (Final)

  • Player bar was moved from #musicMain to bottom of #musicQueuePanel
  • Queue list is scrollable and takes remaining vertical space (flex: 1; min-height: 0; overflow: auto)
  • Player controls stay pinned at bottom of queue panel