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 (
afterCurrentclass) - 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:
— lives inqueue[]musicQueue[]on page— lives incurrentIndexqueueCurrentIndexon page— no longer neededsetQueue()— page layer handles thisplayNext()— page layer handles thisplayPrev()— page readsgetCurrentTrack()musicQueue[queueCurrentIndex]— replaced byplayCurrent()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[]andpushCurrentView() - Remove the custom
← Backbutton (browser back handles it) - Or keep the
← Backbutton but wire it towindow.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
- Add
parseRoute()andnavigate()helper functions - Add
handleRoute()async function that dispatches to the correct view - Wire
hashchangelistener and initial route handling ininitMusicFeature() - Update
runMusicSearch()to callnavigate('/search/...')instead of just rendering - Update
drillDownToAlbum()to callnavigate('/album/...')instead of direct render - Update
drillDownToArtist()to callnavigate('/artist/...')instead of direct render - Update playlist click handlers to call
navigate('/playlist/...') - Replace
musicViewStackback navigation withwindow.history.back() - Add track route handler that fetches metadata and inserts into queue
- 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 indexshuffle: play random index (prefer different from current when possible)loop-queue: play next index with wraparoundnormal: 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
#musicMainto 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