531 lines
19 KiB
Markdown
531 lines
19 KiB
Markdown
# Music Player — Queue System Documentation
|
|
|
|
## Overview
|
|
|
|
The music page ([`www/music.html`](www/music.html:1)) uses a **single unified queue** (`musicQueue[]`) as the source of truth for all playback. The audio engine ([`SimplePlayer`](www/js/greyscale-player.mjs:8)) is a pure playback component — it plays whatever stream URL it receives and fires callbacks when tracks end.
|
|
|
|
As of the Gruuv migration, track discovery/playback is relay-native:
|
|
- Track metadata is read from Nostr events (`kind 36787`, `t=gruuv`) via [`GruuvAPI`](www/js/gruuv-api.mjs:333)
|
|
- Playlist events are published/read as `kind 34139` via [`gruuv-playlists.mjs`](www/js/gruuv-playlists.mjs:1)
|
|
- Track IDs in queue/URLs are now addressable coordinates (`36787:pubkey:dTag`), not numeric Tidal IDs
|
|
- Uploads use Blossom + `kind 24242` auth and publish `kind 36787` via [`uploadGruuvTrack()`](www/js/gruuv-upload.mjs:139)
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```mermaid
|
|
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)
|
|
|
|
```javascript
|
|
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}`:
|
|
```json
|
|
{
|
|
"currentIndex": 2,
|
|
"items": [
|
|
{ "id": "36787:<pubkey>:<dTag>", "title": "...", "artist": "...", "duration": 240, "cover": "...", "albumTitle": "..." }
|
|
]
|
|
}
|
|
```
|
|
|
|
### SimplePlayer (Audio Engine)
|
|
|
|
```javascript
|
|
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
|
|
|
|
```mermaid
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
let playbackMode = 'normal'; // 'normal' | 'loop-track' | 'loop-queue' | 'shuffle'
|
|
```
|
|
|
|
Persisted to localStorage with queue payload (`music-queue:{pubkey}`) as:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```html
|
|
<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
|