rename to client

This commit is contained in:
Laan Tungir
2026-04-17 16:52:51 -04:00
commit a8bbbbafb9
465 changed files with 522173 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
# Music & VJ URL Structure
Unified URL schema for `music.html` and `vj.html`.
## URL Anatomy
```
https://example.com/vj.html?show=reggae&npub=npub1abc#/playlist/ep-1744382400
\______________________/\________/ \_______________/ \________________________/
origin pathname query string hash fragment
```
## Query String Parameters
Query string parameters represent **identity and session context**. They persist across hash navigation changes.
| Parameter | Format | Pages | Required | Description |
|-----------|--------|-------|----------|-------------|
| `npub` | `npub1...` | both | no | Target user public key in npub format (preferred) |
| `pubkey` | 64-char hex | both | no | Target user public key in hex format (fallback) |
| `auth` | `required` \| `optional` \| `none` | both | no | Authentication mode override |
| `show` | slug string | both | no | Active show slug (e.g. `saturday-reggae`) |
| `episode` | identifier string | both | no | Active episode playlist identifier |
| `a` | `30311:{pubkey}:{slug}` | vj.html | no | Stream coordinate for direct stream targeting |
| `naddr` | `naddr1...` | vj.html | no | Nostr address encoding of stream event |
### Parameter Precedence
- `npub` takes priority over `pubkey` when both are present
- `pubkey` is deleted from URL when `npub` is set
- If `npub` or `pubkey` points to a different user than the logged-in user, the page enters **read-only mode**
## Hash Fragment Routes
Hash routes represent **in-page navigation state**. Changing the hash does not reload the page.
| Route | Description |
|-------|-------------|
| `#/` | Home — empty search view |
| `#/search/{query}` | Search results for the given query |
| `#/album/{albumId}` | Album detail drill-down |
| `#/artist/{artistId}` | Artist detail drill-down |
| `#/track/{trackId}` | Play a specific track by ID |
| `#/playlist/{identifier}` | Open own playlist/episode by identifier |
| `#/playlist/{pubkey}/{identifier}` | Open external playlist/episode by pubkey and identifier |
### Route Parsing
Routes are parsed by splitting the hash on `/`:
```
#/playlist/abc123/ep-42 → { page: 'playlist', parts: ['abc123', 'ep-42'] }
#/search/bob marley → { page: 'search', parts: ['bob marley'] }
#/track/98765 → { page: 'track', parts: ['98765'] }
```
## URL Examples
### VJ working on own show
```
vj.html?show=saturday-reggae&episode=ep-1744382400
```
### Sharing an episode for playback on music.html
```
music.html?npub=npub1abc123...#/playlist/npub1abc123.../ep-1744382400
```
### Sharing a VJ episode for viewing
```
vj.html?npub=npub1abc123...&show=saturday-reggae&episode=ep-1744382400
```
### Direct track link (works on either page)
```
music.html#/track/12345678
vj.html#/track/12345678
```
### Search link
```
music.html#/search/bob%20marley
```
### Album link
```
music.html#/album/album-id-here
```
## URL Update Behavior
| User Action | URL Change | Method |
|-------------|-----------|--------|
| Select show in dropdown | `?show={slug}` added/updated | `replaceState` |
| Select/create episode | `?episode={id}` added/updated | `replaceState` |
| Search for music | `#/search/{query}` | hash assignment |
| Click album/artist | `#/album/{id}` or `#/artist/{id}` | hash assignment |
| Play track from link | `#/track/{id}` | hash assignment |
| Select playlist | `#/playlist/{id}` | hash assignment |
| Login / auth change | `?npub={npub}` added | `replaceState` |
| Load external user | `?npub={npub}` or `?pubkey={hex}` | page navigation |
### replaceState vs hash assignment
- **`replaceState`** — Used for context changes (show, episode, auth). Does not create a new history entry. The user does not get a "back" step for every dropdown change.
- **Hash assignment** — Used for navigation changes (search, album, track, playlist). Creates a new history entry. Back/Forward buttons navigate between views.
+544
View File
@@ -0,0 +1,544 @@
# Settings System — Kind 30078
This document is the definitive specification for how user settings and application-specific data are stored, encrypted, synced, and consumed across all projects in this ecosystem (client web pages, Didactyl agent, and future apps).
## Table of Contents
1. [Overview](#1-overview)
2. [NIP-78 Context](#2-nip-78-context)
3. [User-Centric Layout](#3-user-centric-layout)
4. [Centralized User Settings Event](#4-centralized-user-settings-event)
5. [Worker Lifecycle](#5-worker-lifecycle)
6. [Page-Level API](#6-page-level-api)
7. [Standalone Kind 30078 Events](#7-standalone-kind-30078-events)
8. [Cross-Project Alignment: Didactyl](#8-cross-project-alignment-didactyl)
9. [Encryption Standard](#9-encryption-standard)
10. [Adding a New Settings Namespace](#10-adding-a-new-settings-namespace)
11. [Audit Findings and Migration Plan](#11-audit-findings-and-migration-plan)
12. [Key Source Files](#12-key-source-files)
---
## 1. Overview
The app uses **NIP-78 Application-specific Data** (`kind:30078`) for two distinct purposes:
1. **Centralized User Settings** — A single addressable event (`d:user-settings`) containing a namespaced JSON object. This is the user's portable preference file, designed to work across apps.
2. **Standalone Page Data** — Individual addressable events with page-specific `d` tags for large or specialized data stores.
Both are **parameterized replaceable events**: for a given `pubkey + kind + d-tag`, only the latest event is retained by relays.
```mermaid
graph TD
subgraph Centralized - d:user-settings
W[SharedWorker ndk-worker.js]
W -->|hydrate on init| C[IndexedDB cache]
W -->|fetch + decrypt| R[Relays]
W -->|NIP-44 encrypt + publish| R
P1[Page A] -->|getUserSettings| W
P2[Page B] -->|patchUserSettings| W
P3[Page C] -->|onUserSettings| W
D[Didactyl Agent] -->|reads llm from user prefs| R
end
subgraph Standalone - per-page d-tags
S1[todo.html] -->|d:todo| R2[Relays]
S2[cal.html] -->|d:calorie_foods / d:calorie_diary| R2
S3[links.html] -->|d:links| R2
S4[ai.html] -->|d:convo-id with t:client-ai-chat-v1| R2
S5[keep-alive.html] -->|d:relay-list-N with t:relay-list| R2
S6[post.html] -->|d:viewed| R2
end
```
---
## 2. NIP-78 Context
[NIP-78](../reference_repos/nips/78.md) specifies kind `30078` as an addressable event with a `d` tag containing "some reference to the app name and context — or any other arbitrary string." Content and tags can be anything.
**NIP-78 is intentionally a blank canvas.** No NIP defines:
- A standard schema for user preferences
- A convention for d-tag naming
- A cross-app settings format
This means our `d:user-settings` convention is ours to define. If it proves useful across the Nostr ecosystem, it could eventually become a NIP proposal for standardized user preferences.
**Related NIPs:**
- **NIP-44** — The required encryption standard for settings content (NIP-04 is deprecated)
- **NIP-51** — Defines structured lists (kinds 10000-30007) but not app settings
- **NIP-37** — Defines draft wraps (kind 31234) with separate relay lists for private content
---
## 3. User-Centric Layout
From the user's npub perspective, their kind 30078 events should be organized into three categories:
### Category 1: Cross-App User Preferences (`d:user-settings`)
Settings that any compatible app should respect. These follow the user across devices and apps:
| Namespace | Purpose | Example consumers |
|-----------|---------|-------------------|
| `global_llm` | LLM provider, model, API key, multi-provider config | AI pages, skills-edit, Didactyl agent |
| `global_zaps` | Default zap amount, comment, preferred method, mint allowlist | Cashu page, post interactions, any zap button |
| `global_ui` | Theme, language, accessibility preferences | All pages |
| `global_relays` | Relay preferences | All pages |
| `global_experimental` | Feature flags | All pages |
The `global_` prefix distinguishes cross-app settings from page-specific ones at a glance.
### Category 2: Page-Specific Settings (also in `d:user-settings`)
Layout and UI state that only matters to specific pages within client:
| Namespace | Purpose | Owning page |
|-----------|---------|-------------|
| `feed` | Video autoplay | `feed.html` |
| `post` | Viewed scroll behavior | `post.html` |
| `notifications` | Filters, readAt timestamp | `notifications.html` |
| `blobs` | Grid columns, rows per page | `blobs.html` |
| `vjPage` | Streaming sites, columns, draft | `vj.html` |
| `strudel` | Strudel music coding settings | strudel pages |
No prefix — these are clearly page-local by their names.
### Category 3: Standalone Data Events (separate d-tags)
Large data stores that would bloat the centralized settings event:
| d-tag | Purpose | Why standalone |
|-------|---------|---------------|
| `todo` | Todo list items | Can grow large |
| `calorie_foods` / `calorie_diary` | Calorie tracking data | Separate data domains |
| `links` | Bookmarks (Netscape HTML) | Large, compressed |
| `viewed` | Read/unread tracking per follow | Updates frequently |
| `{conversation-id}` | AI chat conversations | Many events, large |
| `relay-list-{N}` | Relay registry chunks | Intentionally public |
| `show-playlist:{show}:{ts}` | VJ episode playlists | Intentionally public |
---
## 4. Centralized User Settings Event
### Nostr Event Shape
```json
{
"kind": 30078,
"tags": [["d", "user-settings"]],
"content": "<NIP-44 encrypted JSON>",
"created_at": 1708646400
}
```
- **`d` tag**: Always `"user-settings"`
- **Encryption**: NIP-44 self-encrypt (sender = recipient = user pubkey)
- **Replaceability**: Addressable — publishing a new one replaces the previous on relays
### Target Schema (v2)
```json
{
"v": 2,
"updatedAt": 1708646400,
"global_llm": {
"provider": "ppq",
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7,
"providers": [
{
"name": "ppq",
"base_url": "https://api.ppq.ai",
"api_key": "sk-...",
"models": ["claude-opus-4.6", "claude-haiku-4.5"]
}
],
"favorites": ["claude-opus-4.6"]
},
"global_zaps": {
"defaultAmountSats": 21,
"defaultComment": "",
"preferredMethod": "auto",
"receiveMintAllowlist": []
},
"global_ui": {},
"global_relays": {},
"global_experimental": {},
"feed": { "videoAutoplay": false },
"post": { "viewed": { "scrollToMark": false } },
"notifications": { "filters": {}, "readAt": 0 },
"blobs": { "gridColumns": 4, "rowsPerPage": 10 },
"vjPage": {
"streamingSites": [],
"selectedSiteName": "",
"streamDraft": {},
"autoAnnounce": {},
"columns": {}
},
"strudel": {}
}
```
### Current Schema (v1) — Supported
The current implementation uses these namespace names without the `global_` prefix:
| Current key | Target key | Status |
|-------------|------------|--------|
| `ui` | `global_ui` | Rename pending |
| `relays` | `global_relays` | Rename pending |
| `experimental` | `global_experimental` | Rename pending |
| `zaps` | `global_zaps` | Rename pending |
| `ai` | `global_llm` | Rename + schema alignment pending |
| `post` | `post` | No change |
| `feed` | `feed` | No change |
| `notifications` | `notifications` | No change |
| `blobs` | `blobs` | No change |
| `vjPage` | `vjPage` | No change |
| `strudel` | `strudel` | No change |
### Default Settings
Defined in [`getDefaultUserSettings()`](../www/ndk-worker.js:324):
```js
{
v: 1, // SETTINGS_SCHEMA_VERSION
updatedAt: 0,
ui: {},
post: { viewed: { scrollToMark: false } },
strudel: {},
relays: {},
experimental: {}
}
```
Feature namespaces not in the defaults (e.g. `feed`, `notifications`, `zaps`, `blobs`, `ai`, `vjPage`) are created on first patch. The [`deepMerge()`](../www/ndk-worker.js:344) function ensures new keys are added without destroying existing ones.
### Normalization
[`normalizeUserSettings()`](../www/ndk-worker.js:359) deep-merges any input with the defaults, then forces:
- `v` to the current `SETTINGS_SCHEMA_VERSION`
- `updatedAt` to a numeric value
---
## 5. Worker Lifecycle
### Hydration (startup)
Called by [`hydrateUserSettingsForPubkey()`](../www/ndk-worker.js:3992) during [`handleInit()`](../www/ndk-worker.js:4271):
```
1. Read from IndexedDB cache (ndk-shared-settings DB, kv store)
2. Normalize and broadcast to all connected tabs
3. Fetch kind 30078 d:user-settings from relays
4. NIP-44 decrypt the content
5. Compare updatedAt timestamps — relay wins if >= local
6. Write winner to IndexedDB cache
7. Broadcast final settings to all tabs
```
### Publishing
Called by [`publishUserSettingsNow()`](../www/ndk-worker.js:4018):
```
1. Set updatedAt to current unix timestamp
2. Normalize the settings object
3. JSON.stringify the normalized object
4. NIP-44 encrypt (self-encrypt: sender = recipient = user pubkey)
5. Create NDKEvent with kind:30078, tags:[['d','user-settings']]
6. Sign and publish to connected relays
```
### Debounced Publish
[`scheduleUserSettingsPublish()`](../www/ndk-worker.js:4055) debounces rapid patches with a 250-350ms delay so multiple quick UI changes result in a single relay publish.
---
## 6. Page-Level API
All functions are exported from [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs) and communicate with the SharedWorker via `postMessage`.
### `getUserSettings()` -> `Promise<Object>`
[Source](../www/js/init-ndk.mjs:956). Returns the current merged settings object. Times out after 7 seconds.
### `patchUserSettings(patch, options?)` -> `Promise<Object>`
[Source](../www/js/init-ndk.mjs:985). Deep-merges `patch` into current settings, writes to cache, broadcasts to all tabs, and (unless `options.publish === false`) schedules a debounced relay publish.
**Convention**: Each page patches only its own namespace:
```js
await patchUserSettings({
myFeature: { someSetting: true }
});
```
### `onUserSettings(callback)` -> `unsubscribe function`
[Source](../www/js/init-ndk.mjs:1015). Subscribes to live settings updates via the `ndkUserSettings` custom DOM event. Returns an unsubscribe function.
### Standard Page Pattern
```js
// 1. Import
import { getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';
// 2. Initial read
let pageSettings = {};
try {
pageSettings = await getUserSettings();
} catch (error) {
pageSettings = {};
}
// 3. Subscribe to live updates
const unsubscribe = onUserSettings((settings) => {
pageSettings = settings || {};
applySettings(pageSettings);
});
// 4. Patch your namespace
await patchUserSettings({ myFeature: { key: value } });
```
---
## 7. Standalone Kind 30078 Events
These are **not** part of the centralized user-settings object. Each page manages its own `d`-tagged event independently.
| d-tag | Page | Encryption | Content Format | Description |
|-------|------|------------|----------------|-------------|
| `todo` | [`todo.html`](../www/todo.html:831) | NIP-04 (legacy) | `{ rows: [todo items] }` | User todo list |
| `calorie_foods` | [`cal.html`](../www/cal.html:975) | NIP-04 (legacy) | `{ rows: [food items] }` | Calorie food database |
| `calorie_diary` | [`cal.html`](../www/cal.html:975) | NIP-04 (legacy) | `{ rows: [diary entries] }` | Daily food diary |
| `links` | [`links.html`](../www/links.html:497) | NIP-04 + LZW (legacy) | Netscape bookmark HTML | Saved bookmarks |
| `viewed` | [`post.html`](../www/post.html) | NIP-44 | `{ v:1, lastGlobalView, follows: {} }` | Read/unread tracking |
| `relay-list-{N}` | [`keep-alive.html`](../www/keep-alive.html:1162) | Plaintext | JSON relay metadata chunks | Relay registry |
| `{conversation-id}` | [`ai.html`](../www/ai.html:788) | NIP-44 | `{ id, title, messages, ... }` | AI chat conversations |
| `show-playlist:{show}:{ts}` | [`vj-stream.mjs`](../www/js/vj-stream.mjs:5) | Plaintext | Episode playlist with track tags | VJ episode playlists |
### Why Standalone?
- **Size**: Todo lists, bookmarks, and conversations can be large
- **Update frequency**: Conversations and playlists update frequently and independently
- **Different encryption**: Some use NIP-04 (legacy), some NIP-44, some are plaintext
- **Different audiences**: Relay registry and playlists are intentionally public
---
## 8. Cross-Project Alignment: Didactyl
### Didactyl's Kind 30078 Usage
The Didactyl agent (a C binary with its own Nostr identity) uses kind 30078 with NIP-44 self-encrypted payloads:
| d-tag | Source | Content |
|-------|--------|---------|
| `user-settings` | [`main.c`](/home/user/lt/didactyl/src/main.c) | `{ v, updatedAt, global_llm, didactyl }` |
| `tasks` | [`tool_task.c:14`](/home/user/lt/didactyl/src/tools/tool_task.c:14) | Agent task memory |
| `memory` | [`tool_memory.c:14`](/home/user/lt/didactyl/src/tools/tool_memory.c:14) | Agent long-term memory |
| Any d-tag | [`tool_config.c:128`](/home/user/lt/didactyl/src/tools/tool_config.c:128) | Generic config_store/config_recall |
### Didactyl `user-settings` Shape
Didactyl now stores runtime LLM + agent metadata in a single `d:user-settings` event under the **agent's own pubkey**:
```json
{
"v": 2,
"updatedAt": 1712345678,
"global_llm": {
"provider": "ppq",
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7
},
"didactyl": {
"admin_pubkey": "npub1...",
"dm_protocol": "nip04"
}
}
```
`model_set` performs read-modify-write of this event by patching `global_llm` while preserving other namespaces.
### Cross-Project Reading
Didactyl does **not** read admin/user web `user-settings` for runtime startup. It only reads/writes:
```
kind:30078, authors:[agent_pubkey], #d:[user-settings]
```
If a web page wants to inspect agent runtime settings, it should query the agent pubkey's `d:user-settings` event and read:
- `global_llm` for model/provider/api settings
- `didactyl` for agent-specific runtime metadata
---
## 9. Encryption Standard
| Context | Method | Rationale |
|---------|--------|-----------|
| Centralized user-settings | NIP-44 self-encrypt | Modern standard; worker handles via messageSigner |
| `viewed` (post read state) | NIP-44 self-encrypt | Privacy — relays cannot see read state |
| AI conversations | NIP-44 self-encrypt | Contains private chat history |
| Didactyl configs | NIP-44 self-encrypt | Contains API keys |
| `todo` | **NIP-04 (legacy — migrate)** | Predates NIP-44 |
| `calorie_foods` / `calorie_diary` | **NIP-04 (legacy — migrate)** | Predates NIP-44 |
| `links` | **NIP-04 + LZW (legacy — migrate)** | Predates NIP-44 |
| `relay-list-{N}` | Plaintext | Intentionally public |
| VJ episode playlists | Plaintext | Intentionally public |
**NIP-04 is deprecated.** All legacy pages should migrate to NIP-44.
---
## 10. Adding a New Settings Namespace
### Step 1: Choose centralized vs standalone
- Small config that benefits from cross-tab sync -> centralized namespace
- Large data, high-frequency updates, or different encryption needs -> standalone d-tag
- Cross-app portable preference -> centralized with `global_` prefix
### Step 2: For centralized (recommended for most page settings)
```js
import { getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';
// Read on init
const settings = await getUserSettings();
const myConfig = settings?.myNewFeature || {};
// Subscribe to live updates
onUserSettings((s) => {
const updated = s?.myNewFeature || {};
applyMyConfig(updated);
});
// Save changes
await patchUserSettings({
myNewFeature: { option1: true, option2: 'value' }
});
```
### Step 3: For standalone
```js
import { subscribe, publishEvent } from './js/init-ndk.mjs';
// Read
subscribe(
{ kinds: [30078], authors: [pubkey], '#d': ['my-feature-data'] },
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);
// Write (use NIP-44 encryption via worker messageSigner)
await publishEvent({
kind: 30078,
tags: [['d', 'my-feature-data']],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000)
});
```
### Step 4: Document
Add the new namespace to the appropriate table in this file.
---
## 11. Audit Findings and Migration Plan
### Issue 1: Duplicate AI Config Storage — RESOLVED
**Previous problem**: AI provider config was stored in two places:
- `skills-edit.html` used standalone user-pubkey `d:llm_config`
- Other AI pages used centralized user settings (`settings.ai` / `global_llm`) via `patchUserSettings`
**Resolution implemented**:
- `skills-edit.html` now reads from centralized user settings `global_llm` (with `ai` fallback for v1 compatibility)
- `skills-edit.html` now writes LLM updates via `patchUserSettings({ global_llm: ... })`
- Standalone user-pubkey `d:llm_config` is deprecated
- Agent-pubkey standalone `d:llm_config` remains the Didactyl runtime config format
### Issue 2: NIP-04 Legacy Encryption — HIGH PRIORITY
**Problem**: Three pages use deprecated NIP-04 encryption:
- [`todo.html`](../www/todo.html:822) — `window.nostr.nip04.encrypt()`
- [`cal.html`](../www/cal.html:972) — `window.nostr.nip04.encrypt()`
- [`links.html`](../www/links.html:501) — `window.nostr.nip04.encrypt()` + LZW
**Migration**: Switch to NIP-44. Add one-time migration: read NIP-04, re-encrypt with NIP-44, republish.
### Issue 3: Direct `window.nostr` Calls — MEDIUM PRIORITY
**Problem**: Standalone pages call `window.nostr.nip04.encrypt/decrypt` directly, bypassing the worker's `messageSigner` infrastructure. This breaks with remote signers/bunkers.
**Migration**: Route encryption through the worker's `messageSigner` for consistency.
### Issue 4: Inconsistent Filter Syntax — LOW PRIORITY
**Problem**: [`cal.html`](../www/cal.html:997) passes filter as an array `[{ kinds: [30078], ... }]` instead of a plain object.
**Migration**: Normalize to object form.
### Issue 5: Missing `cacheUsage: 'CACHE_FIRST'` — LOW PRIORITY
**Problem**: [`todo.html`](../www/todo.html:863), [`cal.html`](../www/cal.html:998), [`links.html`](../www/links.html:434) subscribe without `cacheUsage: 'CACHE_FIRST'`.
**Migration**: Add `cacheUsage: 'CACHE_FIRST'` for faster load times.
### Issue 6: Merge `calorie_foods` + `calorie_diary` — LOW PRIORITY
**Problem**: [`cal.html`](../www/cal.html:1103) publishes two separate events that are always loaded and saved together.
**Migration**: Combine into single `d:calorie` event: `{ foods: {...}, diary: {...} }`.
### Issue 7: Rename to `global_` Prefix — DEFERRED
**Problem**: Current cross-app namespaces (`ai`, `zaps`, `ui`, `relays`, `experimental`) lack the `global_` prefix.
**Migration**: Bump schema to v2. In `normalizeUserSettings()`, detect v1 and migrate: copy `ai` -> `global_llm`, `zaps` -> `global_zaps`, etc. Support reading both during transition.
### Migration Priority Order
1. Merge `skills-edit.html` `d:llm_config` into centralized settings (Issue 1)
2. Migrate `todo.html`, `cal.html`, `links.html` from NIP-04 to NIP-44 (Issue 2)
3. Route standalone encryption through worker messageSigner (Issue 3)
4. Fix `cal.html` filter syntax + add CACHE_FIRST everywhere (Issues 4, 5)
5. Merge calorie events (Issue 6)
6. Rename to `global_` prefix with v2 schema migration (Issue 7)
---
## 12. Key Source Files
| File | Role |
|------|------|
| [`www/ndk-worker.js`](../www/ndk-worker.js:311) | Settings state, hydration, publish, cache, normalize |
| [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs:956) | Page-facing API: getUserSettings, patchUserSettings, onUserSettings |
| [`reference_repos/nips/78.md`](../reference_repos/nips/78.md) | NIP-78 specification for kind 30078 |
| [`reference_repos/nips/44.md`](../reference_repos/nips/44.md) | NIP-44 encryption specification |
| `/home/user/lt/didactyl/src/main.c` | Didactyl agent llm_config and agent_config publish/recall |
| `/home/user/lt/didactyl/src/tools/tool_config.c` | Didactyl generic config_store/config_recall tool |
| `/home/user/lt/didactyl/src/tools/tool_model.c` | Didactyl model_set tool (persists to d:llm_config) |
+551
View File
@@ -0,0 +1,551 @@
# Cache-First Page Implementation Patterns
## Lessons Learned & Best Practices for client Pages
---
## The Core Problem
Every page in this app communicates with Nostr relays through a shared Web Worker via `init-ndk.mjs`. Relay responses are inherently slow and unreliable — they may take seconds, time out after 15s, or never arrive at all. If page initialization `await`s relay fetches, the user stares at a blank screen.
Meanwhile, NDK maintains an IndexedDB cache (via Dexie) that contains previously-seen events. This cache is local and fast (sub-100ms). **Pages must render from cache first, then hydrate from relays in the background.**
---
## The Three Data APIs
### 1. `queryCache(filters)` — Local IndexedDB only (FAST)
- **Timeout**: 5 seconds
- **Source**: Dexie/IndexedDB only, no network
- **Use for**: Initial page render, instant UI population
- **Import**: `import { queryCache } from './js/init-ndk.mjs'`
### 2. `ndkFetchEvents(filters)` — Relay fetch (SLOW)
- **Timeout**: 15 seconds
- **Source**: Nostr relays via NDK in the worker
- **Use for**: Background hydration after cache render
- **Import**: `import { ndkFetchEvents } from './js/init-ndk.mjs'`
- **WARNING**: Never `await` this in the critical render path if cache data exists
### 3. `subscribe(filters, opts)` — Live streaming (ONGOING)
- **No timeout**: Stays open, delivers events via `window 'ndkEvent'` events
- **Source**: Cache first (if `cacheUsage: 'CACHE_FIRST'`), then relays
- **Use for**: Live updates after initial render
- **Import**: `import { subscribe } from './js/init-ndk.mjs'`
---
## The Golden Rule
```
NEVER await a relay call (ndkFetchEvents) in the critical render path
if you can get data from cache first.
```
---
## Standard Page Loading Pattern
```mermaid
sequenceDiagram
participant Page
participant Cache as queryCache - IndexedDB
participant Relay as ndkFetchEvents - Relays
participant Sub as subscribe - Live
Page->>Cache: queryCache filters
Cache-->>Page: cached events - instant
Page->>Page: render UI from cache
Page->>Relay: void ndkFetchEvents filters
Note over Relay: fire-and-forget, no await
Relay-->>Page: relay events - eventually
Page->>Page: merge and re-render if newer
Page->>Sub: subscribe filters, CACHE_FIRST
Note over Sub: stays open for live updates
Sub-->>Page: ndkEvent window events
Page->>Page: upsert and re-render
```
---
## Implementation Template
### Step 1: Import both `queryCache` and `ndkFetchEvents`
```javascript
import {
initNDKPage,
getPubkey,
queryCache,
ndkFetchEvents,
subscribe,
// ... other imports
} from './js/init-ndk.mjs';
```
### Step 2: Cache-first data loading function
```javascript
async function loadPageData() {
const pubkey = await getPubkey();
// Phase 1: Cache (blocking — but fast)
let items = [];
try {
const cached = await queryCache({
kinds: [MY_KIND],
authors: [pubkey],
limit: 50
});
items = Array.isArray(cached) ? cached : [];
renderItems(items);
console.log('[my-page] Rendered from cache:', items.length);
} catch (err) {
console.warn('[my-page] Cache query failed:', err?.message);
}
// Phase 2: Relay hydration (non-blocking — fire and forget)
void ndkFetchEvents({
kinds: [MY_KIND],
authors: [pubkey],
limit: 50
}).then((relayEvents) => {
if (Array.isArray(relayEvents) && relayEvents.length > 0) {
// Merge with existing items, dedupe by id
const merged = mergeAndDedupe(items, relayEvents);
renderItems(merged);
console.log('[my-page] Hydrated from relays:', relayEvents.length);
}
}).catch((err) => {
console.warn('[my-page] Relay hydration failed:', err?.message);
});
// Phase 3: Live subscription for ongoing updates
subscribe(
{ kinds: [MY_KIND], authors: [pubkey] },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
}
```
### Step 3: Handle live events via ndkEvent listener
```javascript
window.addEventListener('ndkEvent', async (event) => {
const evt = event.detail;
if (!evt || evt.kind !== MY_KIND) return;
// Upsert into your data structure and re-render
upsertItem(evt);
renderItems(getAllItems());
});
```
---
## Common Anti-Patterns to Avoid
### Anti-Pattern 1: Awaiting relay fetch in main()
```javascript
// BAD — blocks page for up to 15 seconds
async function main() {
const events = await ndkFetchEvents({ kinds: [1], authors: [pubkey] });
renderFeed(events);
}
```
```javascript
// GOOD — render from cache, hydrate in background
async function main() {
const cached = await queryCache({ kinds: [1], authors: [pubkey] });
renderFeed(cached);
void ndkFetchEvents({ kinds: [1], authors: [pubkey] }).then(renderFeed);
}
```
### Anti-Pattern 2: Sequential relay fetches in a loop
```javascript
// BAD — each iteration blocks for up to 15 seconds
for (const item of items) {
const refs = await ndkFetchEvents({ ids: [item.refId] });
item.image = extractImage(refs[0]);
}
```
```javascript
// GOOD — try cache first, relay in background
for (const item of items) {
let refs = [];
try { refs = await queryCache({ ids: [item.refId] }); } catch (_) {}
const ref = refs.find(r => r.id === item.refId);
item.image = ref ? extractImage(ref) : '';
if (!ref) {
// Fire-and-forget relay lookup
void ndkFetchEvents({ ids: [item.refId] }).then((relayRefs) => {
const relayRef = relayRefs?.find(r => r.id === item.refId);
if (relayRef) {
item.image = extractImage(relayRef);
// Re-render if needed
}
}).catch(() => {});
}
}
```
### Anti-Pattern 3: Awaiting relay fetch for a single event by ID
```javascript
// BAD — blocks detail page for up to 15 seconds
if (isEventMode) {
const events = await ndkFetchEvents({ ids: [targetEventId], limit: 1 });
renderSingleEvent(events[0]);
}
```
```javascript
// GOOD — render from cache instantly, relay hydrates in background
if (isEventMode) {
let matched = null;
try {
const cached = await queryCache({ ids: [targetEventId], limit: 1 });
matched = Array.isArray(cached) ? cached.find(e => e?.id === targetEventId) : null;
} catch (_) {}
if (matched) renderSingleEvent(matched);
void ndkFetchEvents({ ids: [targetEventId], limit: 1 }).then(relayEvents => {
const relayMatch = Array.isArray(relayEvents)
? relayEvents.find(e => e?.id === targetEventId) : null;
if (relayMatch) renderSingleEvent(relayMatch);
}).catch(() => {});
}
```
### Anti-Pattern 4: Embedded content hydration using only relays
When a rendered post contains `nostr:nevent1...` references, the hydration code
fetches the referenced event to display an inline preview. This is a secondary
fetch triggered *after* the main content renders — easy to miss.
```javascript
// BAD — embedded note hydration blocks on relay, shows "Failed to fetch" on timeout
const events = await ndkFetchEventsFn(filter);
```
```javascript
// GOOD — try cache first, fall back to relay
let events = [];
if (queryCacheFn) {
try { events = await queryCacheFn(filter); } catch (_) {}
}
if (!events || events.length === 0) {
events = await ndkFetchEventsFn(filter);
}
```
### Anti-Pattern 5: Interaction data fetched only from relays
Social interaction queries — likes, reposts, zaps, comments — use `#e` tag
filters. These should also be cache-first so the interaction bar populates
instantly on page refresh.
```javascript
// BAD — interaction counts blank for 15 seconds
const allEvents = await ndkFetchEvents(filters);
applyInteractions(allEvents);
```
```javascript
// GOOD — show cached counts immediately, relay updates in background
let hadCache = false;
if (queryCacheFn) {
try {
const cached = await queryCacheFn(filters);
hadCache = applyInteractions(cached, 'cache');
} catch (_) {}
}
if (hadCache) {
void ndkFetchEvents(filters).then(relay => applyInteractions(relay, 'relays')).catch(() => {});
} else {
const relay = await ndkFetchEvents(filters);
applyInteractions(relay, 'relays');
}
```
### Anti-Pattern 6: Module loading that blocks on relays
```javascript
// BAD — configureMuteList + loadMuteList blocks if relay is slow
configureMuteList({ ndkFetchEvents, getPubkey });
await loadMuteList(); // blocks for 15s if no cache
// GOOD — pass queryCache so cache loads instantly, relay hydrates in background
configureMuteList({ ndkFetchEvents, queryCache, getPubkey });
await loadMuteList(); // returns instantly from cache, relays fire-and-forget
```
---
## Module Configuration Checklist
When configuring shared modules like `mute-list.mjs`, always pass **both** `queryCache` and `ndkFetchEvents`:
```javascript
configureMuteList({
ndkFetchEvents,
queryCache, // <-- REQUIRED for cache-first loading
publishEvent,
getPubkey,
nip44Encrypt: ...,
nip44Decrypt: ...
});
```
---
## Profile Resolution
The `profile-cache.mjs` module already implements cache-first patterns internally. When creating a profile cache instance, pass `queryCache`:
```javascript
const profileCache = createProfileCache({
fetchCachedProfile,
ndkFetchEvents,
storeProfile,
queryCache, // <-- enables cache-first profile lookups
refreshIntervalMs: 20 * 60 * 1000
});
```
---
## Timeout Reference
| API | Timeout | Blocking? |
|-----|---------|-----------|
| `queryCache()` | 5s | Yes (but fast — local IndexedDB) |
| `ndkFetchEvents()` | 15s | **Only if you await it** |
| `subscribe()` | None | No (event-driven) |
| `publishEvent()` | 15s | Yes (must await for confirmation) |
---
## Worker Request Latency Pattern (Critical for Wallet Actions)
Some UI actions call worker RPCs via `sendWorkerRequest()` and have strict front-end timeouts (for example, 45s for `walletSendNutzap`, 90s for `walletPayInvoice`).
If a worker handler does both:
1. **critical state updates** (required for user-visible success), and
2. **slow relay-side writes** (proof republish, history events, cleanup),
then awaiting both in sequence can cause front-end timeout errors even when the payment actually succeeded.
### Anti-Pattern 7: Responding after non-critical relay writes
```javascript
// BAD — response blocked behind slow relay publishing
await publishDirectProofs();
await publishSpendingHistoryEvent(tx);
port.postMessage({ type: 'response', requestId, data: successPayload });
```
```javascript
// GOOD — respond after critical path, background the rest
const payload = getWalletBalancePayload();
port.postMessage({ type: 'response', requestId, data: successPayload });
broadcast({ type: 'walletBalanceUpdated', data: payload });
void (async () => {
try { await publishDirectProofs(); } catch (e) { console.warn(e); }
try { await publishSpendingHistoryEvent(tx); } catch (e) { console.warn(e); }
})();
```
### Rule of thumb for worker handlers
- **Await only critical path steps** needed to determine success/failure for the request.
- **Respond immediately** once local state is coherent.
- **Background non-critical relay writes** with `void` async blocks and warning logs.
- **Do not hide failures silently**: warn in logs so reconciliation/debug remains possible.
This is now the expected pattern for latency-sensitive wallet operations like `walletPayInvoice` and `walletSendNutzap`.
---
## Dependency Threading Checklist
When a page passes dependencies to a module, and that module passes them to a
sub-module, `queryCache` must be threaded through **every layer**. Missing it at
any hop means the inner module falls back to relay-only fetches.
```
page.html → post-interactions2.mjs → post-interactions.mjs
queryCache ✅ queryCache ✅ queryCacheFn ✅
```
**Rule**: If you add `queryCache` to a page's dependency object, grep for every
module in the chain that destructures and forwards those deps. Each one must
explicitly destructure and pass `queryCache` through.
```javascript
// post-interactions2.mjs — MUST destructure and forward queryCache
export function initPostCards(deps = {}) {
const { ndkFetchEvents, queryCache, /* ... */ } = deps;
const interactions = initInteractions({
ndkFetchEvents,
queryCache, // <-- MUST be forwarded here
// ...
});
}
```
```javascript
// post-interactions.mjs — MUST accept and store queryCache
let queryCacheFn = null;
export function initInteractions(ndkFunctions = {}) {
const { ndkFetchEvents, queryCache, /* ... */ } = ndkFunctions;
queryCacheFn = typeof queryCache === 'function' ? queryCache : null;
}
```
---
## Render & Decrypt Performance Lessons (from msg.html, v0.4.20v0.4.23)
These lessons were learned during the messaging page optimization cycle. They
apply to any page that does expensive async work (decryption, signing, heavy
DOM rendering) triggered by event-driven callbacks.
### Lesson 1: Measure first, then optimize
Add lightweight `performance.now()` instrumentation to every phase of your
page's critical path. Without timing data, you will guess wrong about where
the bottleneck is. In `msg.html`, we added a rolling perf report system
(`startMsgPerfReport` / `markMsgPerf` / `finalizeMsgPerfReport`) that logs
step-by-step timing to the console and exposes `window.__msgPerfReports` for
programmatic inspection. This immediately revealed that 63 seconds of wall
time was spent in eager decrypt — not in cache queries or DOM rendering.
### Lesson 2: Expensive async render functions need single-flight guards
If `renderThread()` (or any expensive async render function) can be called
from multiple event-driven paths — live subscription events, profile fetches,
gift-wrap processing, relay hydration callbacks — concurrent invocations will
pile up. Each one does the same expensive work (decrypt, DOM build), and all
but the last one get thrown away.
**Pattern**: Wrap the render function in a single-flight coalescing guard:
```javascript
let renderInFlight = null;
let renderQueued = false;
async function renderThread() {
if (renderInFlight) {
renderQueued = true;
return renderInFlight;
}
renderInFlight = (async () => {
do {
renderQueued = false;
await renderThreadInner();
} while (renderQueued);
})().finally(() => { renderInFlight = null; });
return renderInFlight;
}
```
This collapses N concurrent calls into at most 2 executions (current + one
queued rerun).
### Lesson 3: Deduplicate in-flight async work, not just completed results
Caching the *result* of an async operation (e.g., `event._decodedKind4`) is
not enough if multiple callers start the operation before the first one
finishes. You must also deduplicate the *in-flight promise* itself.
**Pattern**: Use a `Map` keyed by operation identity to store the active
promise:
```javascript
const inFlightDecrypts = new Map();
async function decryptEvent(event, counterparty) {
if (event._decoded) return event._decoded;
const key = event.id + ':' + counterparty;
if (inFlightDecrypts.has(key)) return inFlightDecrypts.get(key);
const promise = doActualDecrypt(event, counterparty)
.finally(() => inFlightDecrypts.delete(key));
inFlightDecrypts.set(key, promise);
return promise;
}
```
In `msg.html`, this reduced 290 decrypt calls (29 renders × 10 messages)
down to 10.
### Lesson 4: Status text should reflect progress, not just phase entry
Showing "Decrypting recent messages… 10" and then going silent for 60 seconds
is worse than showing nothing — it looks frozen. Update the status on every
iteration: "Decrypting recent messages… 3/10". This gives the user confidence
that work is happening.
### Lesson 5: Audit every call site that triggers re-render
Non-awaited render calls from places like `ensureProfileName()` and
`processKind4Event()` can fire dozens of times during background hydration.
Each one triggers a full render cycle. Treat render triggers like network
calls: audit them, guard them, and suppress them during batch operations
(e.g., using a `suppressUiRefresh` flag).
### Lesson 6: "Small" per-item latency explodes in serial loops
A 6-second decrypt call seems tolerable for one message. But 10 messages in a
serial loop = 60 seconds. And if that loop runs 29 times concurrently due to
a render storm, the user waits over a minute. Always consider the
multiplicative effect of per-item costs × loop iterations × concurrent
invocations.
### Lesson 7: Cache-first is necessary but not sufficient
Even after making `hydrateConversationHistory()` cache-first (rendering from
cache before relay hydration), the page still stalled because the *render
path itself* contained expensive blocking work (decrypt). Cache-first solves
the data-fetch bottleneck; you still need to address compute bottlenecks in
the render pipeline.
### Lesson 8: Keep perf tooling in production-safe form
Lightweight timing helpers that log to `console.groupCollapsed` and store
results in a bounded array are cheap enough to ship. They cost nothing when
nobody opens DevTools, and they save hours when debugging the next regression.
---
## Summary Rules
1. **Always import `queryCache`** alongside `ndkFetchEvents` in every page
2. **Render from cache first**`await queryCache()` is safe to block on (fast, local)
3. **Never await `ndkFetchEvents` in the render path** — use `void` for fire-and-forget
4. **Use `subscribe` with `cacheUsage: 'CACHE_FIRST'`** for live updates
5. **Pass `queryCache` to all module configurations** (mute-list, profile-cache, etc.)
6. **Thread `queryCache` through every dependency hop** — page → wrapper → inner module
7. **Apply cache-first to secondary fetches** — embedded notes, interaction data, not just main content
8. **Avoid sequential relay fetches in loops** — batch or use cache-first per item
9. **Always handle relay timeouts gracefully** — the cache result is good enough for initial render
10. **Guard expensive async render functions with single-flight coalescing** — prevent render storms
11. **Deduplicate in-flight async work** — cache the promise, not just the result
12. **Audit all re-render trigger sites** — suppress during batch operations
13. **Show incremental progress in status text** — update per-item, not per-phase
14. **For worker RPC handlers, respond before non-critical relay writes** — background republish/history/cleanup work with warning logs
+524
View File
@@ -0,0 +1,524 @@
# 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.
---
## 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": 123, "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