rename to client
This commit is contained in:
@@ -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.
|
||||
@@ -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) |
|
||||
@@ -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.20–v0.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
@@ -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
|
||||
Reference in New Issue
Block a user