Compare commits

...
14 Commits
Author SHA1 Message Date
Laan Tungir 38661b7fb6 . 2026-06-14 08:07:35 -04:00
Laan Tungir eed00f67cf Migrate music playlists to Gruuv a-tag hydration and add optional lazy FRIENDS loading 2026-06-09 15:15:08 -04:00
Laan Tungir 8998c5664d Re-sort projects table by 30-day commits after lazy commit counts load 2026-05-29 10:58:32 -04:00
Laan Tungir e943870d77 Port projects page functionality into template with public/no-login mode and project-native styling 2026-05-28 14:44:00 -04:00
Laan Tungir 08fc661077 Prioritize live window.nostr authState for active signing port registration 2026-05-27 15:48:34 -04:00
Laan Tungir 92430296c2 Fix shared-worker signer authority routing for multi-tab nsigner 2026-05-27 15:27:24 -04:00
Laan Tungir bc8e2f46fd Improve shared worker nsigner routing with explicit active signing port registration 2026-05-27 15:17:45 -04:00
Laan Tungir bd713d01f9 Fix stream site URL template editing and add NIP-53 starts/ends tags 2026-05-03 07:00:46 -04:00
Laan Tungir 7ce913997a This is a meaningful git commit message 2026-04-28 12:33:29 -04:00
Laan Tungir 63f5097a6d This is a meaningful git commit message 2026-04-28 10:44:22 -04:00
Laan Tungir 89fed0c818 This is a meaningful git commit message 2026-04-28 10:39:51 -04:00
Laan Tungir 0c9e8bebcb Implement document page chat binding, JSON editor flow, and resizable columns 2026-04-28 10:00:02 -04:00
Laan Tungir 22334ad432 . 2026-04-21 06:32:47 -04:00
Laan Tungir 3ff4bc532b This is a meaningful git commit message 2026-04-19 14:31:26 -04:00
57 changed files with 13737 additions and 4699 deletions
+4 -4
View File
@@ -51,7 +51,7 @@ 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'] }
#/track/36787:pubkey:dTag → { page: 'track', parts: ['36787:pubkey:dTag'] }
```
## URL Examples
@@ -73,8 +73,8 @@ 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
music.html#/track/36787:aaaaaaaa...:my-track-dtag
vj.html#/track/36787:aaaaaaaa...:my-track-dtag
```
### Search link
@@ -95,7 +95,7 @@ music.html#/album/album-id-here
| 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 |
| Play track from link | `#/track/{id}` (`id` is now typically `36787:pubkey:dTag`) | 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 |
+7 -1
View File
@@ -4,6 +4,12 @@
The music page ([`www/music.html`](www/music.html:1)) uses a **single unified queue** (`musicQueue[]`) as the source of truth for all playback. The audio engine ([`SimplePlayer`](www/js/greyscale-player.mjs:8)) is a pure playback component — it plays whatever stream URL it receives and fires callbacks when tracks end.
As of the Gruuv migration, track discovery/playback is relay-native:
- Track metadata is read from Nostr events (`kind 36787`, `t=gruuv`) via [`GruuvAPI`](www/js/gruuv-api.mjs:333)
- Playlist events are published/read as `kind 34139` via [`gruuv-playlists.mjs`](www/js/gruuv-playlists.mjs:1)
- Track IDs in queue/URLs are now addressable coordinates (`36787:pubkey:dTag`), not numeric Tidal IDs
- Uploads use Blossom + `kind 24242` auth and publish `kind 36787` via [`uploadGruuvTrack()`](www/js/gruuv-upload.mjs:139)
---
## Architecture
@@ -72,7 +78,7 @@ Persisted to localStorage under key `music-queue:{pubkey}`:
{
"currentIndex": 2,
"items": [
{ "id": 123, "title": "...", "artist": "...", "duration": 240, "cover": "...", "albumTitle": "..." }
{ "id": "36787:<pubkey>:<dTag>", "title": "...", "artist": "...", "duration": 240, "cover": "...", "albumTitle": "..." }
]
}
```
+201
View File
@@ -0,0 +1,201 @@
# Document Page (`www/document.html`)
## Overview
A new page, derived from [`www/ai.html`](../www/ai.html:1), that lets the user co-author a long-form Nostr note with an LLM assistant. The page starts as an encrypted draft (**kind 30024**) and can be promoted to a published article (**kind 30023**).
Layout is a 3-column page:
```mermaid
flowchart LR
A[Documents + Skills<br/>left pane] --> B[Chat thread<br/>center pane]
B --> C[Document column<br/>title + markdown editor<br/>markdown/plaintext toggle]
C -.autosave 30024.-> R[(Nostr relays)]
B -.chat turn JSON.-> LLM[LLM]
LLM -.document + chat.-> B
LLM -.document.-> C
B -.autosave 30078 doc-chat:dtag.-> R
```
**There is no "Conversations" list.** The chat log is bound 1:1 to the document. Selecting a document loads its entire past chat; creating a new document creates a new empty chat.
## Approach summary (from Q&A)
| Topic | Decision |
|------|----------|
| LLM response format | **Option A**`response_format: {type: 'json_object'}` returning `{ "chat": "...", "document": "..." }`. LLM always returns the full document. |
| Skill strategy | **Option C (hybrid)** — a hardcoded page-level wrapper enforces the JSON contract; user-selected kind 31123 skills are concatenated *above* it as style/tone layers. |
| Document column behavior | **Editable** textarea (same feel as [`www/note.html`](../www/note.html:1)); both the user and the LLM can write to it. |
| View toggle | Reuse [`dot-menu`](../www/js/dot-menu.mjs:1): "View as markdown", "View as plaintext", "Copy document". |
## 1. HTML structure changes
Starting from the current copy of [`www/ai.html`](../www/ai.html:1) at [`www/document.html`](../www/document.html:1):
- Change `<title>` and header text from "AI" to "Document".
- Rename `#divAiLayout` to a 3-column flex row:
- Left: `#divDocumentsPane` (renamed from `#divAiConversationsPane`) — **Documents list on top, Skills on bottom**. The "Conversations" list is removed entirely.
- Center: `#divAiChatPane` (unchanged internals; just displays the chat log for the currently-selected document).
- **New right pane:** `#divAiDocumentPane` containing:
- Header row with title input, publish-as-30023 button, and a `.doc-menu-host` for the dot-menu.
- Summary input + hashtags input (single line each).
- Image URL input (optional).
- `#taDocument` markdown textarea (main editor).
- `#divDocumentPreview` hidden div for rendered-markdown view mode.
- Small status line ("Saved / UNSAVED", current kind, current `d` tag).
- Empty state: when no document is selected, the chat input is disabled and the document pane shows a placeholder "Select or create a document".
- CSS: `#divAiDocumentPane` is `flex: 1.2; min-width: 420px;` similar styling to the chat pane.
## 2. Document model (ported from note.html)
Port the following from [`www/note.html`](../www/note.html:552) but render the list in the **left column** (not the sidenav):
- `OBJ_NOTES` map keyed by `d` tag.
- `renderDocumentsList()` — renders `OBJ_NOTES` as a vertical list in `#divDocumentsList` (replaces `#divAiConversationsList`). Each row: title (or "Untitled"), small meta (`kind`, last-edited), delete button.
- `LoadNote(d)` — populates `#taDocument`, title/summary/hashtags/image inputs, sets `CURRENT_NOTE = d`, re-renders preview via `marked`, AND triggers `loadDocChat(d)` (see §5.3).
- `newDocument()` — assigns `CURRENT_NOTE = now()`, clears inputs and chat thread. No publish yet; publish happens on first real save.
- `DeleteNote(d)` — kind 5 delete event for the document. Also deletes the paired `doc-chat:<d>` kind-30078 event.
- `Publish30024Note()` — autosave path (unchanged logic).
- `Publish30023Note(d)` — promote draft → published (unchanged logic).
The sidenav drawer keeps only relay / blossom / AI-config sections — no notes table duplicated there.
## 3. Autosave
Mirror note.html's autosave:
- `LastText` baseline vs. current textarea value.
- Footer shows "UNSAVED" vs. "Saved".
- Debounced save on textarea input (e.g. 1500 ms idle). Immediate save when the LLM writes a new document. Immediate save on blur if dirty.
- On save, `Publish30024Note()` keeps the same `d` tag.
## 4. Markdown / plaintext / copy dot-menu
A tiny helper inside `document.html` (no new module needed) uses [`mountDotMenu`](../www/js/dot-menu.mjs:1) on `.doc-menu-host`:
```text
┌───────── Document ⋯ ─────────┐
│ View as markdown │
│ View as plaintext │
│ ────────────────────────────│
│ Copy document │
└──────────────────────────────┘
```
- **Plaintext** (default): show `#taDocument`, hide `#divDocumentPreview`.
- **Markdown**: hide textarea, show `#divDocumentPreview` with `marked.parse(taDocument.value)` (purified via `DOMPurify` already loaded by the page).
- **Copy document**: clipboard copy of the raw textarea value.
The current view mode lives in a `documentViewMode` variable (not per-message like messaging-ui — it's a single document). The pattern is copied from `setMessageViewMode` / `renderBubbleContent` in [`www/js/messaging-ui.mjs`](../www/js/messaging-ui.mjs:36).
## 5. LLM ↔ document wiring
### 5.1 Hardcoded skill wrapper
Add a constant inside `document.html`:
```js
const DOCUMENT_EDITOR_WRAPPER = `
system:
You are collaborating with the user on a single long-form markdown document.
Every reply MUST be valid JSON with exactly these fields:
{
"chat": "<short conversational reply, plain text>",
"document": "<the FULL updated markdown document, or null if unchanged>"
}
Rules:
- Never include commentary outside the JSON.
- Always return the ENTIRE document in "document" when you make ANY edit, even small ones.
- Set "document" to null (or omit it) when the user only asked a question and no edit is needed.
- Preserve the user's existing structure unless they ask you to restructure.
- Keep markdown formatting (headings, lists, code fences) intact.
user:
The current document is below, inside <document></document>. The user's new message follows.
<document>
{{document}}
</document>
User message:
{{message}}
`;
```
This wrapper is concatenated *after* any user-selected kind 31123 skills (so user skills set style/tone, wrapper enforces JSON contract last and therefore wins on conflicting instructions — per the existing "last skill wins" rule in [`plans/ai-skills-integration.md`](ai-skills-integration.md:5)).
### 5.2 Send flow changes
In the existing `sendPrompt()` path (the one already ported from ai.html):
1. Build `combinedSystem` from selected skills as today, then append `DOCUMENT_EDITOR_WRAPPER`'s system portion.
2. Resolve `{{document}}` placeholder with `taDocument.value` and `{{message}}` with the user's input.
3. Call the chat endpoint using [`sendAiChatJson`](../www/js/ai-chat.mjs:72) (already supports `response_format: { type: 'json_object' }`) — replaces the current plain `callOpenAICompatibleChat` path only on this page.
4. On success, `result.json` is `{ chat, document }`:
- Append `chat` as the assistant message in the thread.
- If `document` is a non-empty string and differs from `taDocument.value`, replace the textarea, re-render preview if in markdown view, trigger immediate autosave.
- If `document` is `null`/omitted, skip the document update (chat-only turn).
5. On malformed JSON, fall back to displaying `result.content` as the assistant message and do NOT touch the document. Show an inline warning chip under the message.
### 5.3 Chat storage — bound 1:1 to the document
There is **no separate Conversations concept**. The chat log for a document is persisted as its own addressable event:
| Field | Value |
|-------|-------|
| `kind` | `30078` |
| `d` tag | `doc-chat:<docDTag>` — e.g. `doc-chat:1714310000` |
| `t` tag | `client-document-chat-v1` (distinct from ai.html's `client-ai-chat-v1`) |
| `content` | NIP-44-encrypted JSON `{ schema: 1, messages: [...] }` |
Each message object shape:
```json
{
"id": "uuid-ish",
"role": "user" | "assistant",
"content": "text shown in bubble",
"documentSnapshot": "optional full markdown at time of assistant turn",
"created_at": 1714310015
}
```
Storing the `documentSnapshot` with the assistant message is optional but cheap and gives us a natural undo/rollback surface later.
**Load flow** (`loadDocChat(d)`):
1. Query cache for `{ kinds: [30078], authors: [self], '#d': ['doc-chat:' + d], '#t': ['client-document-chat-v1'] }`.
2. Decrypt `content` with NIP-44.
3. Parse `messages` array and render via the existing ai.html message thread renderer.
4. If no event exists, render empty thread.
**Save flow** (`saveDocChat(d, messages)`):
1. Encrypt `{ schema: 1, messages }` with NIP-44 to self.
2. Publish kind 30078 with tags `[['d', 'doc-chat:' + d], ['t', 'client-document-chat-v1']]`.
3. Because addressable, this replaces the prior version on relays.
**When messages are published**: after each completed assistant turn (chat appended + optional document replaced). User-only drafts in the input box are NOT published.
**Empty-to-first-message transition**: if `CURRENT_NOTE` is a freshly-created doc that has never been saved, sending the first user message runs `Publish30024Note()` first (so the document `d` exists on relays) and then `saveDocChat()`, preventing orphan chats.
**Deletion**: `DeleteNote(d)` publishes two kind-5 events — one for the document event id, one for the `doc-chat:<d>` event id.
## 6. Publish-as-30023 action
A "Publish as article" button in the document column header calls `Publish30023Note(CURRENT_NOTE)` (ported unchanged from note.html), with the same confirmation prompt. After publishing, the page reloads the sidenav list; the draft `d` tag is kept or cleaned up per note.html's existing logic.
## 7. Files touched
| File | Change |
|------|--------|
| [`www/document.html`](../www/document.html:1) | Major modification — new column, note model port, LLM JSON round-trip, dot-menu view toggle. |
| [`plans/document-page.md`](document-page.md:1) | This plan (new). |
No new JS modules are required. Everything reuses existing modules: [`dot-menu.mjs`](../www/js/dot-menu.mjs:1), [`ai-chat.mjs`](../www/js/ai-chat.mjs:1), [`init-ndk.mjs`](../www/js/init-ndk.mjs:1), `marked`, `DOMPurify`.
## 8. Open questions (non-blocking)
- Should we show a visible diff when the LLM rewrites the document (red/green lines) before accepting? — Deferred; v1 just overwrites and relies on Nostr history for rollback.
- Should very large documents be chunked into the prompt? — Deferred; rely on `max_tokens` and surface a truncation warning (`sendAiChatJson` already returns `truncated`).
- Should we debounce-autosave every keystroke or only on blur? — Start with 1500 ms idle + blur; revisit if relay spam shows up.
+187
View File
@@ -0,0 +1,187 @@
# Music: Greyscale → Gruuv API Migration Plan
## Goal
Fully replace the Greyscale (Tidal-proxy) music backend used by [`www/music.html`](../www/music.html) with the **Gruuv** Nostr-native protocol. This includes search, browse, streaming, playlists, and a new track upload capability. Greyscale code is removed once the gruuv path is verified.
A backup of the original page is preserved as [`www/music-greyscale.html`](../www/music-greyscale.html).
---
## Background: Why this is an architecture shift, not a swap
The two backends are fundamentally different in nature.
| Aspect | Greyscale (current) | Gruuv (target) |
|---|---|---|
| Catalog source | Tidal proxy REST API over HTTP instance pool | Nostr relays: `kind 36787` track events tagged `t=gruuv` |
| Search | Server endpoint `/search/?s=` | Relay query + client-side text filter on title/artist/album |
| Streaming | DASH/MP3 manifest decoded from instance | Direct Blossom blob URL (the `url` tag) |
| Track ID | Numeric Tidal ID | Addressable coordinate `36787:pubkey:dTag` |
| Album | Rich `/album/?id=` endpoint | Derived by grouping track events on the `album` tag |
| Artist | Rich `/artist/?id=` endpoint | Derived by grouping track events on the `artist` tag |
| Cover art | `resources.tidal.com/images/...` | The `image` tag on the track event |
| Playlists | `kind 30004`, `i` tags `tidal:track:ID` | `kind 34139`, `a` tags `36787:pubkey:dTag`, `t=gruuv` |
| Upload | N/A (read-only Tidal) | Blossom `PUT /upload` + publish `kind 36787` |
### What already exists in the project (reused, not rebuilt)
- [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs) — provides `subscribe()`, `publishEvent()`, `publishRawEvent()`, `getPubkey()`. The page already wires NDK, relay status UI, and the `ndkEvent` window-event stream.
- [`www/js/blossom-api.mjs`](../www/js/blossom-api.mjs) — provides `calculateSHA256()` and `createBlossomAuth()`, which already builds the **`kind 24242`** authorization event that Gruuv's upload spec requires (see [`gruuv/README.md`](../gruuv/README.md)).
- [`www/js/blossom-ui.mjs`](../www/js/blossom-ui.mjs) — provides `getBlossomServers()` for the active Blossom target.
- [`SimplePlayer`](../www/js/greyscale-player.mjs) — its direct-URL playback path already handles plain audio URLs (Blossom URLs are plain `https`), so playback needs no DASH logic for gruuv.
### Gruuv protocol reference (from `gruuv/`)
`kind 36787` track event tags (per [`gruuv/upload_gruuv.sh`](../gruuv/upload_gruuv.sh) and [`gruuv/README.md`](../gruuv/README.md)):
```
d stable track id / dTag
url blossom file url
title song title
artist artist name
album album name
duration seconds (string)
size bytes (string)
alt "Song: <title> - <artist>"
format mp3 | flac | ...
m audio mime (audio/mpeg, audio/flac, ...)
x sha256 hex
t music
t gruuv
image (optional) cover art url
genre (optional) + t=<genre-lowercase>
released (optional) date-like
track_number (optional) plain numeric string
disc_number (optional, omit when disc 1)
```
`kind 34139` playlist event tags:
```
d playlist id / dTag
title
description
alt "Playlist: <title>"
t gruuv
image (optional)
a 36787:<pubkey>:<dTag> (repeated, one per track)
```
Default relays observed: `wss://relay.primal.net`, `wss://nos.lol`, `wss://relay.damus.io`. Default Blossom: `https://blossom.primal.net`. (The page's own NDK relay config / Blossom server selection should be used in practice.)
---
## Design strategy: drop-in module replacement
To keep [`www/music.html`](../www/music.html) changes minimal and low-risk, the new gruuv modules will expose the **same method names and return shapes** as the current greyscale modules. Then the migration in `music.html` is mostly an import swap plus targeted fixes for the few places where the data model genuinely differs (IDs, derived albums/artists, upload UI).
```mermaid
graph LR
subgraph page [music.html UI - mostly unchanged]
Search[Search box]
Browse[Album / Artist views]
Player[SimplePlayer audio]
Playlists[Playlist panel]
Upload[NEW Upload panel]
end
subgraph mods [New gruuv modules]
API[gruuv-api.mjs]
PL[gruuv-playlists.mjs]
UP[gruuv-upload.mjs]
end
subgraph infra [Existing project infra]
NDK[init-ndk.mjs subscribe publishEvent]
BLOB[blossom-api.mjs sha256 kind 24242 auth]
BUI[blossom-ui.mjs getBlossomServers]
end
Search --> API
Browse --> API
Player --> API
Playlists --> PL
Upload --> UP
API --> NDK
PL --> NDK
UP --> BLOB
UP --> BUI
UP --> NDK
```
---
## Data model mapping (gruuv track event → music.html track shape)
The UI expects the normalized shape produced by [`GreyscaleAPI.prepareTrack()`](../www/js/greyscale-api.mjs:186):
`{ id, title, artist, duration, cover, albumTitle, raw }`.
Mapping from a `kind 36787` event:
| UI field | Source |
|---|---|
| `id` | addressable coordinate `36787:<pubkey>:<d-tag>` (fallback to event id) |
| `title` | `title` tag |
| `artist` | `artist` tag (fallback "Unknown artist") |
| `duration` | `duration` tag parsed to number |
| `cover` | `image` tag (no Tidal URL transform) |
| `albumTitle` | `album` tag |
| `streamUrl` | `url` tag (used by `getTrackStream`) |
| `raw` | full event for later reference |
`getCoverUrl()` / `getArtistPictureUrl()` become pass-through helpers (return the URL as-is) so existing render code keeps working.
---
## Implementation steps
### Phase 0 — Safety
1. Confirm [`www/music-greyscale.html`](../www/music-greyscale.html) is a faithful backup of the original greyscale page so it can be referenced/restored.
### Phase 1 — Read path (search, browse, stream)
2. Create [`www/js/gruuv-api.mjs`](../www/js/gruuv-api.mjs) exposing the same surface as `GreyscaleAPI`:
`searchTracks`, `searchAlbums`, `searchArtists`, `getAlbum`, `getArtist`, `getTrackStream`, `getCoverUrl`, `getArtistPictureUrl`, `prepareTrack`.
3. Implement search by subscribing to `kind 36787` with `#t: ['gruuv']`, collecting events until EOSE (with a timeout), then client-side filtering on title/artist/album. Provide a small in-memory cache of fetched track events to back album/artist derivation.
4. Implement `getAlbum` / `getArtist` by grouping the cached/fetched track events on the `album` and `artist` tags respectively (gruuv has no album/artist DB). `getAlbum(id)` returns `{ album, tracks }`; `getArtist(id)` returns `{ ...artist, albums, eps, tracks }` to match existing render expectations.
5. Implement `getTrackStream` to return `{ streamUrl: <url tag>, isDash: false }`. Cover/artwork helpers return the `image` URL directly.
### Phase 2 — Playlists
6. Create [`www/js/gruuv-playlists.mjs`](../www/js/gruuv-playlists.mjs) mirroring the exports of [`greyscale-playlists.mjs`](../www/js/greyscale-playlists.mjs):
`PLAYLIST_KIND` (now `34139`), `buildPlaylistEvent`, `parsePlaylistEvent`, `isMusicPlaylistEvent`, `createPlaylistIdentifier`, `playlistEventAddress`, and any helper the page imports.
- Track references use `a` tags of form `36787:<pubkey>:<dTag>` instead of `i`/`tidal:track:` tags.
- Topic tag becomes `t=gruuv` (keep `t=music` for discoverability if useful).
- Keep a content JSON cache of track metadata (title/artist/cover/duration) like the greyscale version so playlists render before track events resolve.
### Phase 3 — Upload (new capability)
7. Create [`www/js/gruuv-upload.mjs`](../www/js/gruuv-upload.mjs):
- `calculateSHA256()` (reuse from blossom-api).
- Build `kind 24242` auth via `createBlossomAuth('upload', sha256, 'Upload <filename>')`.
- `PUT <activeBlossomServer>/upload` with the `Authorization: Nostr <base64>` header; fall back to `<server>/<sha256>` for the URL if the response lacks one.
- Optionally read duration via an `<audio>`/`AudioContext` decode client-side; allow manual title/artist/album entry.
- Publish `kind 36787` via `publishEvent()` with the full tag set (including `t=music`, `t=gruuv`, `x`, `m`, `format`, `size`, `alt`).
### Phase 4 — Wire into music.html
8. Swap imports: replace [`GreyscaleAPI`](../www/music.html:1371), the greyscale playlists import block ([lines ~13751380](../www/music.html:1375)) with gruuv modules. Keep `SimplePlayer` (it works with direct URLs); the DASH branch simply goes unused.
9. Update search handlers ([~3117](../www/music.html:3117), combined search [~4516](../www/music.html:4516)) and detail loaders ([~3837](../www/music.html:3837)[~3963](../www/music.html:3963)) for the gruuv id format and the grouped album/artist results.
10. Update playlist subscription filter and event handling ([`subscribeAllPlaylists`](../www/music.html:3288), [`handleMusicNdkEvent`](../www/music.html:3299), [`hydratePlaylistFromEvent`](../www/music.html:3245)) to `kind 34139` with `t=gruuv` and `a`-tag track refs.
11. Add an **Upload Track** UI section (file input, metadata fields, progress/status), gated behind authentication (`promptLoginIfNeeded`), wired to `gruuv-upload.mjs`.
12. Update the download ([`fetchDownloadBlobForTrack`](../www/music.html:4342)) and share flows to use the direct Blossom URL instead of Tidal stream manifests.
### Phase 5 — Cleanup & verification
13. Remove greyscale modules ([`greyscale-api.mjs`](../www/js/greyscale-api.mjs), [`greyscale-playlists.mjs`](../www/js/greyscale-playlists.mjs), and [`greyscale-player.mjs`](../www/js/greyscale-player.mjs) if no longer referenced) and any greyscale-only code paths — only after the gruuv path is verified. (Player may be retained/renamed if still used for playback.)
14. Run the build inline-check ([`build/.music-inline-check.mjs`](../build/.music-inline-check.mjs)) and do a relay smoke test (cross-check against [`gruuv/list_gruuv_tracks.sh`](../gruuv/list_gruuv_tracks.sh) output). Verify an upload round-trips: publish `36787`, then it appears in search.
15. Update docs: [`docs/music.md`](../docs/music.md) and [`docs/MUSIC_URL_STRUCTURE.md`](../docs/MUSIC_URL_STRUCTURE.md) to reflect gruuv kinds/IDs and the new upload feature.
---
## Risks & decisions
- **Relay-native search has no ranking/pagination like a server endpoint.** Mitigation: fetch with a sensible `limit`, cache events, filter client-side, debounce queries. Result completeness depends on relay coverage.
- **Albums/artists are derived, not authoritative.** Grouping by free-text `album`/`artist` tags can produce duplicates/variants. Mitigation: normalize/trim, dedupe like the existing `deduplicateAlbums`.
- **Track addressing change** (`36787:pubkey:dTag` vs numeric ID) affects deep links and URL structure — covered by the `MUSIC_URL_STRUCTURE.md` update.
- **Upload duration/metadata** extraction is best-effort in the browser (no ffprobe); manual fields are the reliable fallback.
- **Default relay/Blossom set**: plan uses the page's existing NDK relays and `getBlossomServers()`; gruuv defaults are documented as fallback. Confirm if a specific relay/Blossom set is required.
## Open question for confirmation
- Should the deep-link/URL scheme keep numeric-style IDs (with a translation layer) or move fully to `36787:pubkey:dTag` coordinates?
+1 -1
View File
@@ -799,7 +799,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -743,7 +743,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -684,7 +684,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
//////////////////////////////////////////////////////////////////////
+1 -1
View File
@@ -301,7 +301,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -402,7 +402,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -466,7 +466,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, encryptContent, decryptContent } from './js/init-ndk.mjs';
+1 -1
View File
@@ -718,7 +718,7 @@
<script src="./nostr.bundle.js"></script>
<script src="./ndk-core.bundle.js?v=wallet-hotfix-3"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
<script type="module">
+1 -1
View File
@@ -210,7 +210,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -1,7 +1,7 @@
.msg-thread-pane {
flex: 1;
min-height: 0;
border: 2px solid var(--primary-color);
/* border: 2px solid var(--primary-color); */
border-radius: 10px;
background: var(--secondary-color);
display: flex;
+1 -1
View File
@@ -378,7 +378,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -513,7 +513,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
+4454
View File
File diff suppressed because it is too large Load Diff
+86 -3
View File
@@ -342,6 +342,7 @@
<div class="panelTitle">Kinds</div>
<div class="btnRow">
<button id="btnRefreshKinds" class="btnMini" type="button">Refresh Cache</button>
<button id="btnGetAllEvents" class="btnMini" type="button">Get all events</button>
</div>
<div id="divKindsStatus" class="hint">Loading...</div>
<div id="divKindsList" class="listWrap"></div>
@@ -432,7 +433,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
@@ -683,6 +684,9 @@
let selectedKindSub = null;
let currentDetailEvent = null;
let lastDecryptResult = null;
let allEventsFetchSub = null;
let allEventsFetchInFlight = false;
let allEventsFetchTimeoutId = null;
const eventsByKind = new Map(); // kind -> Map(eventKey, event)
const selectedEventKeys = new Set();
@@ -703,6 +707,7 @@
const divFooterRight = document.getElementById('divFooterRight');
const btnRefreshKinds = document.getElementById('btnRefreshKinds');
const btnGetAllEvents = document.getElementById('btnGetAllEvents');
const divKindsStatus = document.getElementById('divKindsStatus');
const divKindsList = document.getElementById('divKindsList');
@@ -1396,16 +1401,22 @@
};
}
function getCachedKindsAndEventTotals() {
const kinds = getKindsSorted();
const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0);
return { kindsCount: kinds.length, totalEvents };
}
function renderKinds() {
const kinds = getKindsSorted();
if (!kinds.length) {
divKindsList.innerHTML = '<div class="hint">No cached events found.</div>';
divKindsStatus.textContent = '0 kinds · 0 events';
if (!allEventsFetchInFlight) divKindsStatus.textContent = '0 kinds · 0 events';
return;
}
const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0);
divKindsStatus.textContent = `${kinds.length} kinds · ${totalEvents.toLocaleString()} cached events`;
if (!allEventsFetchInFlight) divKindsStatus.textContent = `${kinds.length} kinds · ${totalEvents.toLocaleString()} cached events`;
divKindsList.innerHTML = kinds.map((kind) => {
const count = getEventsForKind(kind).length;
@@ -1682,6 +1693,63 @@
selectedKindSub = subscribe({ kinds: [Number(kind)], authors: [currentPubkey], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
}
function clearAllEventsFetchTimeout() {
if (allEventsFetchTimeoutId) {
clearTimeout(allEventsFetchTimeoutId);
allEventsFetchTimeoutId = null;
}
}
function closeAllEventsFetchSubscription() {
try {
if (allEventsFetchSub && typeof allEventsFetchSub.close === 'function') allEventsFetchSub.close();
} catch (_error) { }
allEventsFetchSub = null;
}
function finalizeGetAllEventsFetch(statusText) {
clearAllEventsFetchTimeout();
closeAllEventsFetchSubscription();
allEventsFetchInFlight = false;
if (btnGetAllEvents) btnGetAllEvents.disabled = false;
if (statusText) {
divKindsStatus.textContent = statusText;
} else {
const { kindsCount, totalEvents } = getCachedKindsAndEventTotals();
divKindsStatus.textContent = `${kindsCount} kinds · ${totalEvents.toLocaleString()} cached events`;
}
}
async function runOneShotGetAllEventsFetch() {
if (allEventsFetchInFlight) return;
if (!currentPubkey) {
divKindsStatus.textContent = 'Get all events failed: user pubkey unavailable';
return;
}
allEventsFetchInFlight = true;
if (btnGetAllEvents) btnGetAllEvents.disabled = true;
const beforeTotals = getCachedKindsAndEventTotals();
divKindsStatus.textContent = `Getting all events from relays for ${shortHex(currentPubkey)}...`;
try {
closeAllEventsFetchSubscription();
allEventsFetchSub = subscribe(
{ authors: [currentPubkey] },
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);
allEventsFetchTimeoutId = setTimeout(() => {
const afterTotals = getCachedKindsAndEventTotals();
const added = Math.max(0, afterTotals.totalEvents - beforeTotals.totalEvents);
finalizeGetAllEventsFetch(`Get all events timed out · +${added.toLocaleString()} events (${afterTotals.totalEvents.toLocaleString()} total)`);
}, 30000);
} catch (error) {
finalizeGetAllEventsFetch(`Get all events failed: ${error?.message || String(error)}`);
}
}
async function selectKind(kind, options = {}) {
const { autoRefresh = true } = options;
@@ -1992,6 +2060,8 @@
});
logoutButton?.addEventListener('click', async () => {
closeAllEventsFetchSubscription();
clearAllEventsFetchTimeout();
try { await logout(); } catch (error) { console.error('[event-management.html] logout failed', error); }
});
@@ -2004,6 +2074,10 @@
}
});
btnGetAllEvents?.addEventListener('click', async () => {
await runOneShotGetAllEventsFetch();
});
inpEventSearch.addEventListener('input', renderEvents);
chkShowSuperseded?.addEventListener('change', renderEvents);
@@ -2319,6 +2393,15 @@
renderEvents();
});
window.addEventListener('ndkEose', (event) => {
if (!allEventsFetchInFlight || !allEventsFetchSub?.subId) return;
const eoseSubId = String(event?.detail?.subId || '');
if (!eoseSubId || eoseSubId !== String(allEventsFetchSub.subId)) return;
const { kindsCount, totalEvents } = getCachedKindsAndEventTotals();
finalizeGetAllEventsFetch(`Get all events complete · ${kindsCount} kinds · ${totalEvents.toLocaleString()} cached events`);
});
window.addEventListener('ndkEvent', (event) => {
const evt = event?.detail;
if (!evt || !Number.isFinite(Number(evt.kind)) || !evt.id) return;
+1 -1
View File
@@ -139,7 +139,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -876,7 +876,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -363,7 +363,7 @@
================================================================ -->
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+542
View File
@@ -0,0 +1,542 @@
import { ndkFetchEvents, queryCache } from './init-ndk.mjs';
const TRACK_KIND = 36787;
const GRUUV_TOPIC = 'gruuv';
const MUSIC_TOPIC = 'music';
const DEFAULT_LIMIT = 600;
function normalizeText(value = '') {
return String(value || '').trim();
}
function normalizeLower(value = '') {
return normalizeText(value).toLowerCase();
}
function safeNumber(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function getTagValues(tags, key) {
if (!Array.isArray(tags)) return [];
return tags
.filter((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string')
.map((t) => t[1]);
}
function getTagValue(tags, key) {
const values = getTagValues(tags, key);
return values[0] || '';
}
function normalizeSlug(value = '') {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 96);
}
function parseTrackCoordinate(id = '') {
const raw = String(id || '').trim();
if (!raw.startsWith(`${TRACK_KIND}:`)) return null;
const parts = raw.split(':');
if (parts.length < 3) return null;
const kind = Number(parts[0]);
const pubkey = String(parts[1] || '').trim().toLowerCase();
const dTag = parts.slice(2).join(':').trim();
if (kind !== TRACK_KIND) return null;
if (!/^[a-f0-9]{64}$/i.test(pubkey) || !dTag) return null;
return { kind, pubkey, dTag };
}
function isHexEventId(value = '') {
return /^[a-f0-9]{64}$/i.test(String(value || '').trim());
}
function inferFormatFromMime(mime = '') {
const m = String(mime || '').toLowerCase();
if (m.includes('flac')) return 'flac';
if (m.includes('mpeg') || m.includes('mp3')) return 'mp3';
if (m.includes('aac')) return 'aac';
if (m.includes('ogg')) return 'ogg';
if (m.includes('wav')) return 'wav';
return '';
}
function hasTopic(tags, topic) {
const lower = normalizeLower(topic);
return getTagValues(tags, 't').some((v) => normalizeLower(v) === lower);
}
function buildTrackIdFromEvent(event) {
const pubkey = normalizeLower(event?.pubkey || '');
const dTag = normalizeText(getTagValue(event?.tags, 'd'));
if (pubkey && dTag) return `${TRACK_KIND}:${pubkey}:${dTag}`;
return normalizeText(event?.id || '');
}
function parseReleasedYear(released = '') {
const match = String(released || '').match(/(\d{4})/);
return match ? Number(match[1]) : 0;
}
function sortByRecent(a, b) {
return safeNumber(b?.created_at, 0) - safeNumber(a?.created_at, 0);
}
function dedupeEvents(events, { requireTopic = true } = {}) {
const byAddress = new Map();
const byEventId = new Map();
for (const event of events || []) {
if (!event || typeof event !== 'object') continue;
if (event.kind !== TRACK_KIND || !Array.isArray(event.tags)) continue;
if (requireTopic && !(hasTopic(event.tags, GRUUV_TOPIC) || hasTopic(event.tags, MUSIC_TOPIC))) continue;
const eventId = normalizeLower(event.id || '');
if (eventId && byEventId.has(eventId)) continue;
const address = buildTrackIdFromEvent(event);
const existing = byAddress.get(address);
if (!existing || safeNumber(event.created_at, 0) >= safeNumber(existing.created_at, 0)) {
byAddress.set(address, event);
if (eventId) byEventId.set(eventId, event);
}
}
return [...byAddress.values()].sort(sortByRecent);
}
function eventToNormalizedTrack(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
const id = buildTrackIdFromEvent(event);
const title = normalizeText(getTagValue(tags, 'title')) || 'Unknown title';
const artist = normalizeText(getTagValue(tags, 'artist')) || 'Unknown artist';
const albumTitle = normalizeText(getTagValue(tags, 'album'));
const duration = safeNumber(getTagValue(tags, 'duration'), 0);
const cover = normalizeText(getTagValue(tags, 'image'));
const streamUrl = normalizeText(getTagValue(tags, 'url'));
const format = normalizeText(getTagValue(tags, 'format')) || inferFormatFromMime(getTagValue(tags, 'm'));
const mime = normalizeText(getTagValue(tags, 'm'));
const sha256 = normalizeText(getTagValue(tags, 'x'));
const released = normalizeText(getTagValue(tags, 'released'));
const trackNumber = normalizeText(getTagValue(tags, 'track_number') || getTagValue(tags, 'track'));
const discNumber = normalizeText(getTagValue(tags, 'disc_number') || getTagValue(tags, 'disc'));
const genre = normalizeText(getTagValue(tags, 'genre'));
const albumId = albumTitle ? `album:${normalizeSlug(`${artist} ${albumTitle}`)}` : '';
const artistId = artist ? `artist:${normalizeSlug(artist)}` : '';
return {
id,
eventId: normalizeText(event?.id || ''),
pubkey: normalizeText(event?.pubkey || ''),
dTag: normalizeText(getTagValue(tags, 'd')),
title,
artist,
artistId,
duration,
cover,
albumTitle,
albumId,
streamUrl,
format,
mime,
sha256,
size: safeNumber(getTagValue(tags, 'size'), 0),
released,
releaseYear: parseReleasedYear(released),
genre,
trackNumber,
discNumber,
createdAt: safeNumber(event?.created_at, 0),
raw: event,
};
}
function shapeSearchResponse(items) {
const rows = Array.isArray(items) ? items : [];
return {
items: rows,
limit: rows.length,
offset: 0,
totalNumberOfItems: rows.length,
};
}
function trackMatchesQuery(track, query) {
const q = normalizeLower(query);
if (!q) return true;
const haystacks = [
track?.title,
track?.artist,
track?.albumTitle,
track?.id,
track?.eventId,
track?.dTag,
track?.genre,
]
.map((v) => normalizeLower(v))
.filter(Boolean);
return haystacks.some((v) => v.includes(q));
}
function groupTracksByAlbum(tracks) {
const byAlbum = new Map();
for (const track of tracks || []) {
const title = normalizeText(track?.albumTitle);
if (!title) continue;
const artist = normalizeText(track?.artist || 'Unknown artist');
const key = `${normalizeLower(artist)}::${normalizeLower(title)}`;
if (!byAlbum.has(key)) {
byAlbum.set(key, {
id: track.albumId || `album:${normalizeSlug(`${artist} ${title}`)}`,
title,
cover: normalizeText(track?.cover || ''),
artist: { name: artist, id: track.artistId || `artist:${normalizeSlug(artist)}` },
artistName: artist,
releaseDate: track.released || '',
numberOfTracks: 0,
explicit: false,
type: 'ALBUM',
tracks: [],
});
}
const album = byAlbum.get(key);
album.tracks.push(track);
album.numberOfTracks = album.tracks.length;
if (!album.cover && track.cover) album.cover = track.cover;
if (!album.releaseDate && track.released) album.releaseDate = track.released;
}
return [...byAlbum.values()].sort((a, b) => {
const yearDiff = parseReleasedYear(b.releaseDate) - parseReleasedYear(a.releaseDate);
if (yearDiff !== 0) return yearDiff;
return a.title.localeCompare(b.title);
});
}
function groupTracksByArtist(tracks) {
const byArtist = new Map();
for (const track of tracks || []) {
const name = normalizeText(track?.artist);
if (!name) continue;
const key = normalizeLower(name);
if (!byArtist.has(key)) {
byArtist.set(key, {
id: track.artistId || `artist:${normalizeSlug(name)}`,
name,
picture: normalizeText(track?.cover || ''),
tracks: [],
});
}
const artist = byArtist.get(key);
artist.tracks.push(track);
if (!artist.picture && track.cover) artist.picture = track.cover;
}
return [...byArtist.values()].sort((a, b) => a.name.localeCompare(b.name));
}
async function safeFetchWithFilter(filters, options = {}) {
const [cacheResult, relayResult] = await Promise.allSettled([
queryCache(filters),
ndkFetchEvents(filters),
]);
const cacheEvents = cacheResult.status === 'fulfilled' && Array.isArray(cacheResult.value)
? cacheResult.value
: [];
const relayEvents = relayResult.status === 'fulfilled' && Array.isArray(relayResult.value)
? relayResult.value
: [];
return dedupeEvents([...cacheEvents, ...relayEvents], options);
}
export class GruuvAPI {
constructor(options = {}) {
this.options = {
limit: Number.isFinite(options.limit) ? Number(options.limit) : DEFAULT_LIMIT,
...options,
};
this.trackCache = new Map();
this.lastTrackSnapshot = [];
}
async initInstances() {
return {
api: ['nostr:kind36787'],
streaming: ['blossom:url-tag'],
};
}
cacheTracks(tracks) {
const rows = Array.isArray(tracks) ? tracks : [];
for (const track of rows) {
if (!track?.id) continue;
this.trackCache.set(String(track.id), track);
if (track.eventId) this.trackCache.set(String(track.eventId), track);
}
this.lastTrackSnapshot = rows;
}
async fetchTrackEvents(filters = {}) {
const mergedFilter = {
kinds: [TRACK_KIND],
'#t': [GRUUV_TOPIC],
limit: this.options.limit,
...filters,
};
const events = await safeFetchWithFilter(mergedFilter);
const tracks = events.map(eventToNormalizedTrack).filter((track) => track.streamUrl);
this.cacheTracks(tracks);
return tracks;
}
prepareTrack(track) {
if (!track || typeof track !== 'object') return null;
if (track.raw && Array.isArray(track.raw.tags)) {
return eventToNormalizedTrack(track.raw);
}
const normalized = {
id: normalizeText(track.id || ''),
title: normalizeText(track.title || '') || 'Unknown title',
artist: normalizeText(track.artist || '') || 'Unknown artist',
duration: safeNumber(track.duration, 0),
cover: normalizeText(track.cover || ''),
albumTitle: normalizeText(track.albumTitle || ''),
raw: track.raw || track,
};
if (!normalized.id && track.raw?.pubkey && Array.isArray(track.raw?.tags)) {
normalized.id = buildTrackIdFromEvent(track.raw);
}
return normalized;
}
getCoverUrl(coverId) {
return normalizeText(coverId);
}
getArtistPictureUrl(id) {
return normalizeText(id);
}
async searchTracks(query) {
const q = normalizeText(query);
if (!q) return shapeSearchResponse([]);
const asCoordinate = parseTrackCoordinate(q);
if (asCoordinate) {
const tracks = await this.fetchTrackEvents({
authors: [asCoordinate.pubkey],
'#d': [asCoordinate.dTag],
limit: 20,
});
return shapeSearchResponse(tracks.filter((t) => String(t.id) === q));
}
const tracks = await this.fetchTrackEvents();
const filtered = tracks.filter((track) => trackMatchesQuery(track, q));
return shapeSearchResponse(filtered);
}
async searchAlbums(query) {
const q = normalizeText(query);
if (!q) return shapeSearchResponse([]);
const tracks = await this.fetchTrackEvents();
const albums = groupTracksByAlbum(tracks).filter((album) => {
const haystack = `${album.title} ${album.artistName}`.toLowerCase();
return haystack.includes(normalizeLower(q));
});
return shapeSearchResponse(albums);
}
async searchArtists(query) {
const q = normalizeText(query);
if (!q) return shapeSearchResponse([]);
const tracks = await this.fetchTrackEvents();
const artists = groupTracksByArtist(tracks).filter((artist) => {
const haystack = `${artist.name}`.toLowerCase();
return haystack.includes(normalizeLower(q));
});
return shapeSearchResponse(artists);
}
async getAlbum(id) {
const albumId = normalizeText(id);
if (!albumId) throw new Error('Album id is required');
const tracks = await this.fetchTrackEvents();
const albums = groupTracksByAlbum(tracks);
const album = albums.find((a) => String(a.id) === albumId);
if (!album) throw new Error('Album not found');
const sortedTracks = [...album.tracks].sort((a, b) => {
const aDisc = safeNumber(a.discNumber, 1);
const bDisc = safeNumber(b.discNumber, 1);
if (aDisc !== bDisc) return aDisc - bDisc;
const aTrack = safeNumber(a.trackNumber, Number.MAX_SAFE_INTEGER);
const bTrack = safeNumber(b.trackNumber, Number.MAX_SAFE_INTEGER);
if (aTrack !== bTrack) return aTrack - bTrack;
return a.title.localeCompare(b.title);
});
return { album: { ...album, tracks: undefined }, tracks: sortedTracks };
}
async getArtist(artistId) {
const id = normalizeText(artistId);
if (!id) throw new Error('Artist id is required');
const tracks = await this.fetchTrackEvents();
const artists = groupTracksByArtist(tracks);
const artist = artists.find((a) => String(a.id) === id);
if (!artist) throw new Error('Artist not found');
const artistTracks = [...artist.tracks].sort((a, b) => {
const yearDiff = safeNumber(b.releaseYear, 0) - safeNumber(a.releaseYear, 0);
if (yearDiff !== 0) return yearDiff;
return a.title.localeCompare(b.title);
});
const albums = groupTracksByAlbum(artistTracks)
.map((album) => ({ ...album, tracks: undefined }))
.filter((album) => normalizeLower(album.artistName) === normalizeLower(artist.name));
return {
id: artist.id,
name: artist.name,
picture: artist.picture,
albums,
eps: [],
tracks: artistTracks.slice(0, 20),
};
}
async getTracksByAddresses(addresses = []) {
const input = Array.isArray(addresses) ? addresses : [];
const normalizedAddresses = [...new Set(
input
.map((value) => parseTrackCoordinate(value))
.filter(Boolean)
.map((coord) => `${TRACK_KIND}:${coord.pubkey}:${coord.dTag}`)
)];
console.log('[gruuv-api] getTracksByAddresses:start', {
requested: input.length,
normalized: normalizedAddresses.length,
normalizedAddresses,
});
const byAddress = new Map();
const unresolvedByAuthor = new Map();
for (const address of normalizedAddresses) {
const cached = this.trackCache.get(address);
if (cached?.streamUrl) {
byAddress.set(address, cached);
continue;
}
const coord = parseTrackCoordinate(address);
if (!coord) continue;
const set = unresolvedByAuthor.get(coord.pubkey) || new Set();
set.add(coord.dTag);
unresolvedByAuthor.set(coord.pubkey, set);
}
console.log('[gruuv-api] getTracksByAddresses:cache', {
cacheHits: byAddress.size,
unresolvedAuthors: unresolvedByAuthor.size,
});
for (const [author, dTagsSet] of unresolvedByAuthor.entries()) {
const dTags = [...dTagsSet].filter(Boolean);
if (!dTags.length) continue;
const filter = {
kinds: [TRACK_KIND],
authors: [author],
'#d': dTags,
limit: Math.max(120, dTags.length * 3),
};
console.log('[gruuv-api] getTracksByAddresses:fetch', {
author,
dTags,
filter,
});
const events = await safeFetchWithFilter(filter, { requireTopic: false });
console.log('[gruuv-api] getTracksByAddresses:events', {
author,
dTagsRequested: dTags.length,
eventsFetched: events.length,
eventIds: events.map((event) => event?.id).filter(Boolean),
});
const tracks = events
.map(eventToNormalizedTrack)
.filter((track) => track.streamUrl);
this.cacheTracks(tracks);
for (const track of tracks) {
if (!track?.id) continue;
byAddress.set(String(track.id), track);
}
}
console.log('[gruuv-api] getTracksByAddresses:done', {
resolved: byAddress.size,
resolvedIds: [...byAddress.keys()],
});
return byAddress;
}
async getTrackStream(id) {
const target = normalizeText(id);
if (!target) throw new Error('Track id is required');
let track = this.trackCache.get(target) || null;
if (!track && parseTrackCoordinate(target)) {
const lookup = await this.searchTracks(target);
const items = Array.isArray(lookup?.items) ? lookup.items : [];
track = items.find((item) => String(item?.id) === target) || items[0] || null;
}
if (!track && isHexEventId(target)) {
const tracks = await this.fetchTrackEvents({ ids: [target], limit: 20 });
track = tracks.find((item) => String(item.eventId) === target) || null;
}
if (!track) throw new Error('Track not found');
if (!track.streamUrl) throw new Error('Track has no stream URL');
return { streamUrl: track.streamUrl, isDash: false };
}
}
+199
View File
@@ -0,0 +1,199 @@
const PLAYLIST_KIND = 34139;
const TRACK_KIND = 36787;
function getTagValue(tags, key) {
if (!Array.isArray(tags)) return '';
const tag = tags.find((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string');
return tag?.[1] || '';
}
function getTagValues(tags, key) {
if (!Array.isArray(tags)) return [];
return tags
.filter((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string')
.map((t) => t[1]);
}
function normalizeSlug(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
function safeJsonParse(value, fallback) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function parseTrackAddress(address = '') {
const raw = String(address || '').trim();
const parts = raw.split(':');
if (parts.length < 3) return null;
if (Number(parts[0]) !== TRACK_KIND) return null;
const pubkey = String(parts[1] || '').trim().toLowerCase();
const dTag = parts.slice(2).join(':').trim();
if (!/^[a-f0-9]{64}$/i.test(pubkey) || !dTag) return null;
return {
kind: TRACK_KIND,
pubkey,
dTag,
address: `${TRACK_KIND}:${pubkey}:${dTag}`,
};
}
function buildTrackTag(track) {
const id = String(track?.id || '').trim();
const parsed = parseTrackAddress(id);
if (parsed) return ['a', parsed.address];
const rawPubkey = String(track?.raw?.pubkey || track?.pubkey || '').trim().toLowerCase();
const dTag = String(
track?.dTag
|| getTagValue(track?.raw?.tags, 'd')
|| track?.identifier
|| ''
).trim();
if (/^[a-f0-9]{64}$/i.test(rawPubkey) && dTag) {
return ['a', `${TRACK_KIND}:${rawPubkey}:${dTag}`];
}
return null;
}
function buildTrackCache(track) {
const id = String(track?.id || '').trim();
if (!id) return null;
return {
id,
title: typeof track?.title === 'string' ? track.title : '',
artist: typeof track?.artist === 'string' ? track.artist : '',
duration: Number.isFinite(track?.duration) ? Number(track.duration) : 0,
cover: typeof track?.cover === 'string' ? track.cover : '',
albumTitle: typeof track?.albumTitle === 'string' ? track.albumTitle : '',
streamUrl: typeof track?.streamUrl === 'string' ? track.streamUrl : '',
};
}
export function createPlaylistIdentifier(title = '') {
const slug = normalizeSlug(title);
if (!slug) return `${Date.now()}`;
return `${slug}-${Date.now()}`;
}
export function isMusicPlaylistEvent(event) {
if (!event || event.kind !== PLAYLIST_KIND || !Array.isArray(event.tags)) return false;
const topics = getTagValues(event.tags, 't').map((t) => t.toLowerCase());
if (topics.includes('gruuv') || topics.includes('music') || topics.includes('playlist')) {
return true;
}
const hasIdentifier = Boolean(getTagValue(event.tags, 'd'));
const hasTitle = Boolean(getTagValue(event.tags, 'title'));
const hasTrackRefs = getTagValues(event.tags, 'a').some((value) => Boolean(parseTrackAddress(value)));
return hasIdentifier && (hasTitle || hasTrackRefs);
}
export function buildPlaylistEvent(playlist, createdAt = Math.floor(Date.now() / 1000)) {
const identifier = playlist?.identifier || playlist?.id || createPlaylistIdentifier(playlist?.title || '');
const title = typeof playlist?.title === 'string' ? playlist.title.trim() : '';
const description = typeof playlist?.description === 'string' ? playlist.description.trim() : '';
const image = typeof playlist?.image === 'string' ? playlist.image.trim() : '';
const banner = typeof playlist?.banner === 'string' ? playlist.banner.trim() : '';
const inputTracks = Array.isArray(playlist?.tracks) ? playlist.tracks : [];
const cacheTracks = inputTracks.map(buildTrackCache).filter(Boolean);
const seen = new Set();
const trackTags = [];
for (const track of inputTracks) {
const tag = buildTrackTag(track);
if (!tag) continue;
const value = tag[1];
if (seen.has(value)) continue;
seen.add(value);
trackTags.push(tag);
}
const tags = [
['d', identifier],
['title', title || 'Untitled playlist'],
['alt', `Playlist: ${title || 'Untitled playlist'}`],
['t', 'gruuv'],
['t', 'music'],
['t', 'playlist'],
];
if (description) tags.push(['description', description]);
if (image) tags.push(['image', image]);
if (banner) tags.push(['banner', banner]);
tags.push(...trackTags);
return {
created_at: createdAt,
kind: PLAYLIST_KIND,
tags,
content: JSON.stringify({ tracks: cacheTracks }),
};
}
export function parsePlaylistEvent(event) {
if (!event || event.kind !== PLAYLIST_KIND) return null;
if (!Array.isArray(event.tags)) return null;
const identifier = getTagValue(event.tags, 'd') || '';
const title = getTagValue(event.tags, 'title') || 'Untitled playlist';
const description = getTagValue(event.tags, 'description') || '';
const image = getTagValue(event.tags, 'image') || '';
const banner = getTagValue(event.tags, 'banner') || '';
const aValues = getTagValues(event.tags, 'a');
const trackIdsFromTags = aValues
.map((v) => parseTrackAddress(v)?.address || '')
.filter(Boolean);
const parsedContent = safeJsonParse(event.content || '', {});
const cacheTracks = Array.isArray(parsedContent?.tracks)
? parsedContent.tracks.map(buildTrackCache).filter(Boolean)
: [];
const cachedById = new Map(cacheTracks.map((t) => [String(t.id), t]));
const trackIds = [...new Set(trackIdsFromTags)];
const tracks = trackIds.map((id) => {
const cached = cachedById.get(id);
if (cached) return cached;
return { id, title: '', artist: '', duration: 0, cover: '', albumTitle: '', streamUrl: '' };
});
return {
eventId: event.id || '',
pubkey: event.pubkey || '',
createdAt: Number(event.created_at) || 0,
identifier,
title,
description,
image,
banner,
tracks,
trackIds,
raw: event,
};
}
export function playlistEventAddress(pubkey, identifier) {
if (!pubkey || !identifier) return '';
return `${PLAYLIST_KIND}:${pubkey}:${identifier}`;
}
export { PLAYLIST_KIND, parseTrackAddress, buildTrackTag };
+204
View File
@@ -0,0 +1,204 @@
import { publishEvent, getPubkey } from './init-ndk.mjs';
import { calculateSHA256, createBlossomAuth } from './blossom-api.mjs';
import { getBlossomServers } from './blossom-ui.mjs';
const TRACK_KIND = 36787;
function normalizeText(value = '') {
return String(value || '').trim();
}
function extensionFromName(name = '') {
const base = String(name || '').trim();
const idx = base.lastIndexOf('.');
if (idx < 0) return '';
return base.slice(idx + 1).toLowerCase();
}
function inferFormat(file) {
const ext = extensionFromName(file?.name || '');
if (ext) return ext;
const mime = normalizeText(file?.type || '').toLowerCase();
if (mime.includes('flac')) return 'flac';
if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3';
if (mime.includes('aac')) return 'aac';
if (mime.includes('ogg')) return 'ogg';
if (mime.includes('wav')) return 'wav';
return 'bin';
}
function inferMime(file) {
const mime = normalizeText(file?.type || '');
if (mime) return mime;
const format = inferFormat(file);
if (format === 'mp3') return 'audio/mpeg';
if (format === 'flac') return 'audio/flac';
if (format === 'ogg') return 'audio/ogg';
if (format === 'aac') return 'audio/aac';
if (format === 'wav') return 'audio/wav';
return 'application/octet-stream';
}
function selectUploadServer() {
const servers = getBlossomServers();
const healthyUpload = servers.find((s) => s?.healthy && s?.upload);
if (healthyUpload?.url) return String(healthyUpload.url).trim().replace(/\/$/, '');
const anyUpload = servers.find((s) => s?.upload);
if (anyUpload?.url) return String(anyUpload.url).trim().replace(/\/$/, '');
const healthyAny = servers.find((s) => s?.healthy);
if (healthyAny?.url) return String(healthyAny.url).trim().replace(/\/$/, '');
if (servers[0]?.url) return String(servers[0].url).trim().replace(/\/$/, '');
return '';
}
async function getAudioDurationSeconds(file) {
if (!(file instanceof File || file instanceof Blob)) return 0;
const objectUrl = URL.createObjectURL(file);
try {
const duration = await new Promise((resolve) => {
const audio = document.createElement('audio');
const cleanup = () => {
audio.removeAttribute('src');
audio.load();
};
audio.preload = 'metadata';
audio.onloadedmetadata = () => {
const d = Number(audio.duration);
cleanup();
resolve(Number.isFinite(d) && d > 0 ? d : 0);
};
audio.onerror = () => {
cleanup();
resolve(0);
};
audio.src = objectUrl;
});
return Math.round(duration);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function makeDTag({ title, artist, sha256 }) {
const safeTitle = normalizeText(title)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
const safeArtist = normalizeText(artist)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32);
const prefix = [safeArtist, safeTitle].filter(Boolean).join('-') || 'track';
return `${prefix}-${String(sha256 || '').slice(0, 8)}-${Date.now()}`;
}
async function uploadFileToBlossom(file, serverUrl, sha256) {
const authToken = await createBlossomAuth('upload', sha256, `Upload ${file.name || 'audio'}`);
const res = await fetch(`${serverUrl}/upload`, {
method: 'PUT',
mode: 'cors',
cache: 'no-cache',
headers: {
'Content-Type': inferMime(file),
Authorization: `Nostr ${authToken}`,
},
body: file,
});
if (!res.ok) {
throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
}
const payload = await res.json().catch(() => ({}));
const url = normalizeText(payload?.url || payload?.blob?.url || '');
return {
uploadUrl: url || `${serverUrl}/${sha256}`,
raw: payload,
};
}
export async function uploadGruuvTrack(file, metadata = {}, options = {}) {
if (!file) throw new Error('No file selected');
const pubkey = await getPubkey();
if (!pubkey) throw new Error('Not authenticated');
const serverUrl = normalizeText(options.serverUrl || selectUploadServer());
if (!serverUrl) throw new Error('No Blossom server available for upload');
const sha256 = await calculateSHA256(file);
const uploadResult = await uploadFileToBlossom(file, serverUrl, sha256);
const title = normalizeText(metadata.title || file.name?.replace(/\.[^.]+$/, '') || 'Untitled');
const artist = normalizeText(metadata.artist || 'Unknown artist');
const album = normalizeText(metadata.album || 'Uploads');
const image = normalizeText(metadata.image || '');
const genre = normalizeText(metadata.genre || '');
const released = normalizeText(metadata.released || '');
const trackNumber = normalizeText(metadata.trackNumber || '');
const discNumber = normalizeText(metadata.discNumber || '');
const duration = Number.isFinite(metadata.duration)
? Number(metadata.duration)
: await getAudioDurationSeconds(file);
const format = inferFormat(file);
const mime = inferMime(file);
const dTag = normalizeText(metadata.dTag || makeDTag({ title, artist, sha256 }));
const tags = [
['d', dTag],
['url', uploadResult.uploadUrl],
['title', title],
['artist', artist],
['album', album],
['duration', String(Math.max(0, Math.round(duration || 0)))],
['size', String(file.size || 0)],
['alt', `Song: ${title} - ${artist}`],
['format', format],
['m', mime],
['x', sha256],
['t', 'music'],
['t', 'gruuv'],
];
if (image) tags.push(['image', image]);
if (genre) {
tags.push(['genre', genre]);
tags.push(['t', genre.toLowerCase()]);
}
if (released) tags.push(['released', released]);
if (trackNumber) tags.push(['track_number', trackNumber]);
if (discNumber && discNumber !== '1') tags.push(['disc_number', discNumber]);
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: TRACK_KIND,
tags,
content: `Listen to my song - ${title} by ${artist}`,
};
const publishResult = await publishEvent(event);
return {
file,
pubkey,
sha256,
url: uploadResult.uploadUrl,
dTag,
event,
publishResult,
};
}
+23 -3
View File
@@ -184,7 +184,7 @@ export async function initNDKPage(options = {}) {
// All pages in www/ directory, so worker path is always the same.
// Include explicit worker revision token so updated SharedWorker code is guaranteed to restart.
const WORKER_REVISION = 'relay-events-db-5';
const WORKER_REVISION = 'relay-events-db-6';
const workerPath = `./ndk-worker.js?v=${encodeURIComponent(VERSION)}&wr=${encodeURIComponent(WORKER_REVISION)}`;
// Initialize NDK SharedWorker (shared across all tabs/pages)
@@ -217,6 +217,7 @@ export async function initNDKPage(options = {}) {
const readyHandler = () => {
cleanup();
console.log('[init-ndk] Worker signaled ready, initialization complete');
tryRegisterActiveSigningPort();
resolve();
};
@@ -273,7 +274,8 @@ async function ensureAuthenticated() {
extension: true,
local: true,
nip46: true,
seedphrase: true
seedphrase: true,
nsigner: true
}
});
}
@@ -319,6 +321,7 @@ async function ensureAuthenticated() {
try {
const pubkey = await getPublicKeyWithTimeout(4000);
console.log('[init-ndk] Authentication successful, pubkey:', pubkey);
tryRegisterActiveSigningPort();
finish(true);
} catch (error) {
console.log('[init-ndk] window.nostr not ready yet:', error.message);
@@ -335,6 +338,7 @@ async function ensureAuthenticated() {
window.addEventListener('nlAuth', authHandler);
window.addEventListener('nlAuthRestored', authHandler);
window.addEventListener('nlMethodSelected', authHandler);
window.addEventListener('nlAuthStateChanged', authHandler);
});
}
@@ -484,6 +488,18 @@ function isExpectedSignerCapabilityError(method, error) {
/**
* Handle sign request from worker
*/
function tryRegisterActiveSigningPort() {
if (!ndkWorker?.port) return;
try {
const auth = window?.nostr?.authState || window?.NOSTR_LOGIN_LITE?.getAuthState?.() || null;
if (auth?.method === 'nsigner' && auth?.signer?.driver) {
ndkWorker.port.postMessage({ type: 'setActiveSigningPort' });
}
} catch (_) {
// ignore
}
}
async function handleSignRequest(requestId, method, params) {
try {
let result;
@@ -526,6 +542,9 @@ async function handleSignRequest(requestId, method, params) {
requestId: requestId,
result: result
});
// If this tab successfully signs using hardware signer, pin worker routing to this tab.
tryRegisterActiveSigningPort();
} catch (error) {
if (isExpectedSignerCapabilityError(method, error)) {
@@ -996,7 +1015,8 @@ export async function injectHeaderLoginButton() {
extension: true,
local: true,
nip46: true,
seedphrase: true
seedphrase: true,
nsigner: true
}
});
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.4",
"VERSION_NUMBER": "0.7.4",
"BUILD_DATE": "2026-04-19T18:00:32.861Z"
"VERSION": "v0.7.18",
"VERSION_NUMBER": "0.7.18",
"BUILD_DATE": "2026-06-14T12:07:35.533Z"
}
+26 -3
View File
@@ -9,6 +9,7 @@ const STATS_POLL_INTERVAL = 10000;
const DEFAULT_STREAMING_SITE = Object.freeze({
name: 'laantungir.net',
streamBaseUrl: 'https://laantungir.net',
streamUrlTemplate: '{base}/stream/{slug}/stream.m3u8',
rtmpServer: 'rtmp://laantungir.net:1935/publish',
obsKeyTemplate: '{slug}/src/{SECRET_KEY}',
});
@@ -18,11 +19,13 @@ function normalizeStreamingSiteConfig(site) {
const input = site && typeof site === 'object' ? site : {};
const name = String(input.name || '').trim() || DEFAULT_STREAMING_SITE.name;
const streamBaseUrl = String(input.streamBaseUrl || '').trim() || DEFAULT_STREAMING_SITE.streamBaseUrl;
const streamUrlTemplate = String(input.streamUrlTemplate || '').trim() || DEFAULT_STREAMING_SITE.streamUrlTemplate;
const rtmpServer = String(input.rtmpServer || '').trim() || DEFAULT_STREAMING_SITE.rtmpServer;
const obsKeyTemplate = String(input.obsKeyTemplate || '').trim() || DEFAULT_STREAMING_SITE.obsKeyTemplate;
return {
name,
streamBaseUrl: streamBaseUrl.replace(/\/+$/, ''),
streamUrlTemplate,
rtmpServer,
obsKeyTemplate,
};
@@ -85,8 +88,12 @@ function deriveShowUrls(slug, siteConfig = DEFAULT_STREAMING_SITE) {
}
const base = site.streamBaseUrl;
const masterPlaylist = String(site.streamUrlTemplate || '').trim()
.replace(/\{base\}/gi, base)
.replace(/\{slug\}/gi, safeSlug)
|| `${base}/stream/${safeSlug}/stream.m3u8`;
return {
masterPlaylist: `${base}/stream/${safeSlug}/stream.m3u8`,
masterPlaylist,
viewerPage: `${base}/stream/${safeSlug}`,
stats: `${base}/api/stream/stats?show=${encodeURIComponent(safeSlug)}`,
obsServer: site.rtmpServer,
@@ -220,10 +227,11 @@ function parseStreamTargetFromUrl() {
}
function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, episodeId, episodeDescription, webUrl }) {
const safeStatus = String(status || 'planned').trim() || 'planned';
const tags = [
['d', dTag],
['title', String(title || '').trim() || 'Untitled stream'],
['status', String(status || 'planned').trim() || 'planned']
['status', safeStatus]
];
const safeSummary = String(summary || '').trim();
@@ -232,6 +240,7 @@ function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, ep
const safeEpisodeId = String(episodeId || '').trim();
const safeEpisodeDescription = String(episodeDescription || '').trim();
const safeWeb = String(webUrl || '').trim();
const nowUnix = String(Math.floor(Date.now() / 1000));
if (safeSummary) tags.push(['summary', safeSummary]);
if (safeImage) tags.push(['image', safeImage]);
@@ -240,6 +249,14 @@ function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, ep
if (safeEpisodeDescription) tags.push(['episode_description', safeEpisodeDescription]);
if (safeWeb) tags.push(['web', safeWeb]);
// NIP-53 compatibility: include timestamps so clients don't default to Unix epoch.
// Prefer episodeId timestamp when available because it is set when the episode starts.
const startsAt = /^\d+$/.test(safeEpisodeId) ? safeEpisodeId : nowUnix;
tags.push(['starts', startsAt]);
if (safeStatus === 'ended') {
tags.push(['ends', nowUnix]);
}
return tags;
}
@@ -279,6 +296,7 @@ export function initVjStreamPanel({
streamingSiteFormPanel: document.getElementById('streamingSiteFormPanel'),
inputSiteName: document.getElementById('inputSiteName'),
inputSiteStreamBaseUrl: document.getElementById('inputSiteStreamBaseUrl'),
inputSiteStreamUrlTemplate: document.getElementById('inputSiteStreamUrlTemplate'),
inputSiteRtmpServer: document.getElementById('inputSiteRtmpServer'),
inputSiteObsKeyTemplate: document.getElementById('inputSiteObsKeyTemplate'),
btnSaveStreamingSite: document.getElementById('btnSaveStreamingSite'),
@@ -435,6 +453,9 @@ export function initVjStreamPanel({
if (els.inputSiteStreamBaseUrl) {
els.inputSiteStreamBaseUrl.value = String(seedSite?.streamBaseUrl || '').trim();
}
if (els.inputSiteStreamUrlTemplate) {
els.inputSiteStreamUrlTemplate.value = String(seedSite?.streamUrlTemplate || '').trim();
}
if (els.inputSiteRtmpServer) {
els.inputSiteRtmpServer.value = String(seedSite?.rtmpServer || '').trim();
}
@@ -450,7 +471,7 @@ export function initVjStreamPanel({
function upsertStreamingSite(draft, { mode = 'add', editingName = '' } = {}) {
const normalizedDraft = normalizeStreamingSiteConfig(draft);
if (!normalizedDraft.name || !normalizedDraft.streamBaseUrl || !normalizedDraft.rtmpServer || !normalizedDraft.obsKeyTemplate) {
if (!normalizedDraft.name || !normalizedDraft.streamBaseUrl || !normalizedDraft.streamUrlTemplate || !normalizedDraft.rtmpServer || !normalizedDraft.obsKeyTemplate) {
return false;
}
@@ -1780,6 +1801,7 @@ export function initVjStreamPanel({
showStreamingSiteForm('add', {
name: '',
streamBaseUrl: 'https://',
streamUrlTemplate: '{base}/stream/{slug}/stream.m3u8',
rtmpServer: 'rtmp://',
obsKeyTemplate: '{slug}/src/{SECRET_KEY}',
});
@@ -1807,6 +1829,7 @@ export function initVjStreamPanel({
const draft = normalizeStreamingSiteConfig({
name: String(els.inputSiteName?.value || '').trim(),
streamBaseUrl: String(els.inputSiteStreamBaseUrl?.value || '').trim(),
streamUrlTemplate: String(els.inputSiteStreamUrlTemplate?.value || '').trim(),
rtmpServer: String(els.inputSiteRtmpServer?.value || '').trim(),
obsKeyTemplate: String(els.inputSiteObsKeyTemplate?.value || '').trim(),
});
+1 -1
View File
@@ -241,7 +241,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -457,7 +457,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
File diff suppressed because it is too large Load Diff
+411 -204
View File
@@ -221,6 +221,38 @@
margin: 4px 0 2px;
}
#musicUploadSection {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(255, 255, 255, 0.02);
}
#musicUploadSection input,
#musicUploadSection button {
width: 100%;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 8px;
font-size: 12px;
background: var(--secondary-color);
color: var(--primary-color);
outline: none;
}
#musicUploadSection button {
cursor: pointer;
}
#musicUploadStatus {
font-size: 11px;
color: var(--muted-color);
min-height: 16px;
}
#musicPlaylistProfile {
border: var(--border);
border-radius: var(--border-radius);
@@ -932,15 +964,6 @@
min-width: 52px;
}
#musicSaveToggleBtn {
min-width: 52px;
}
#musicSaveToggleBtn[data-enabled='true'] {
border-color: var(--accent-color);
color: var(--accent-color);
}
#musicProgressWrap {
display: grid;
grid-template-columns: auto 1fr auto;
@@ -1145,7 +1168,10 @@
<div class="musicSidebarTitle">MY PLAYLISTS</div>
<ul id="musicMyPlaylistsList" class="musicPlaylistList"></ul>
<div class="musicSidebarTitle">FRIENDS</div>
<div class="musicSidebarTitle" style="display:flex; align-items:center; justify-content:space-between; gap:8px;">
<span>FRIENDS</span>
<button id="musicLoadFriendsBtn" class="btn musicMiniBtn" type="button">Load</button>
</div>
<ul id="musicFriendPlaylistsList" class="musicPlaylistList"></ul>
<div class="musicSidebarTitle">AI IMPORT</div>
@@ -1161,6 +1187,16 @@
<div id="musicImportStatus"></div>
<div id="musicImportResults"></div>
</section>
<div class="musicSidebarTitle">UPLOAD TRACK</div>
<section id="musicUploadSection">
<input id="musicUploadFile" type="file" accept="audio/*" />
<input id="musicUploadTitle" type="text" placeholder="Title (optional)" />
<input id="musicUploadArtist" type="text" placeholder="Artist (optional)" />
<input id="musicUploadAlbum" type="text" placeholder="Album (optional)" />
<button id="musicUploadBtn" class="btn" type="button">Upload to Gruuv</button>
<div id="musicUploadStatus">Sign in to upload.</div>
</section>
</aside>
<div class="musicGutter resizableColumnsGutter" data-gutter-index="0" aria-hidden="true"></div>
@@ -1226,7 +1262,6 @@
<div id="musicPlayerControls">
<button id="musicPlaybackModeBtn" class="btn musicControlBtn" type="button" title="Playback mode"></button>
<button id="musicSaveToggleBtn" class="btn musicControlBtn" type="button" title="Auto-save now playing track to Blossom" aria-pressed="false">💾</button>
<button id="musicPrevBtn" class="btn musicControlBtn" type="button"></button>
<button id="musicPlayPauseBtn" class="btn musicControlBtn" type="button"></button>
<button id="musicNextBtn" class="btn musicControlBtn" type="button">▶▶</button>
@@ -1343,7 +1378,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
@@ -1377,19 +1412,18 @@
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { uploadToServer, getDefaultBlossomServer } from './js/blossom-api.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { GreyscaleAPI } from './js/greyscale-api.mjs';
import { GruuvAPI } from './js/gruuv-api.mjs';
import { SimplePlayer } from './js/greyscale-player.mjs';
import { sendAiChatJson } from './js/ai-chat.mjs';
import { uploadGruuvTrack } from './js/gruuv-upload.mjs';
import {
PLAYLIST_KIND,
buildPlaylistEvent,
createPlaylistIdentifier,
parsePlaylistEvent,
isMusicPlaylistEvent,
} from './js/greyscale-playlists.mjs';
} from './js/gruuv-playlists.mjs';
import { mountDotMenu } from './js/dot-menu.mjs';
import { initResizableColumns } from './js/resizable-columns.mjs';
import {
@@ -1478,6 +1512,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
playlistDescriptionInput: document.getElementById('musicPlaylistDescriptionInput'),
myPlaylistsList: document.getElementById('musicMyPlaylistsList'),
friendPlaylistsList: document.getElementById('musicFriendPlaylistsList'),
loadFriendsBtn: document.getElementById('musicLoadFriendsBtn'),
importDropZone: document.getElementById('musicImportDropZone'),
importInput: document.getElementById('musicImportInput'),
importProcessing: document.getElementById('musicImportProcessing'),
@@ -1486,7 +1521,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
queueClearBtn: document.getElementById('musicQueueClearBtn'),
queueList: document.getElementById('musicQueueList'),
playbackModeBtn: document.getElementById('musicPlaybackModeBtn'),
saveToggleBtn: document.getElementById('musicSaveToggleBtn'),
shareModal: document.getElementById('musicShareModal'),
shareTarget: document.getElementById('musicShareTarget'),
shareRendered: document.getElementById('musicShareRendered'),
@@ -1496,9 +1530,15 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
viewerPubkeyInput: document.getElementById('musicViewerPubkeyInput'),
viewerLoadBtn: document.getElementById('musicViewerLoadBtn'),
viewerInfo: document.getElementById('musicViewerInfo'),
uploadFileInput: document.getElementById('musicUploadFile'),
uploadTitleInput: document.getElementById('musicUploadTitle'),
uploadArtistInput: document.getElementById('musicUploadArtist'),
uploadAlbumInput: document.getElementById('musicUploadAlbum'),
uploadBtn: document.getElementById('musicUploadBtn'),
uploadStatus: document.getElementById('musicUploadStatus'),
};
const musicApi = new GreyscaleAPI();
const musicApi = new GruuvAPI();
const musicPlayer = new SimplePlayer({
audio: musicEls.audio,
progressEl: musicEls.progress,
@@ -1515,6 +1555,8 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
const myPlaylists = new Map();
const friendPlaylists = new Map();
let friendsFeedEnabled = false;
let playlistSubscription = null;
let musicQueue = [];
let queueCurrentIndex = -1;
let musicResultsMode = 'search';
@@ -1543,11 +1585,11 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
};
let isHandlingRoute = false;
let playbackMode = 'normal';
let autoSaveToBlossom = false;
let currentPlayingTrackId = '';
let pendingShareUrl = '';
let pendingShareArtUrl = '';
let pendingShareLabel = '';
let isGruuvUploadRunning = false;
const BLANK_IMAGE_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
const MUSIC_COLUMN_STORAGE_KEY = 'music-column-widths:v1';
@@ -1619,6 +1661,76 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
musicEls.status.dataset.type = type;
}
function setUploadStatus(text) {
if (!musicEls.uploadStatus) return;
musicEls.uploadStatus.textContent = String(text || '').trim();
}
function updateUploadUiState() {
const canUpload = Boolean(isAuthenticated && currentPubkey);
if (musicEls.uploadBtn) {
musicEls.uploadBtn.disabled = !canUpload || isGruuvUploadRunning;
}
if (musicEls.uploadFileInput) {
musicEls.uploadFileInput.disabled = isGruuvUploadRunning;
}
if (musicEls.uploadTitleInput) {
musicEls.uploadTitleInput.disabled = isGruuvUploadRunning;
}
if (musicEls.uploadArtistInput) {
musicEls.uploadArtistInput.disabled = isGruuvUploadRunning;
}
if (musicEls.uploadAlbumInput) {
musicEls.uploadAlbumInput.disabled = isGruuvUploadRunning;
}
if (!canUpload && !isGruuvUploadRunning) {
setUploadStatus('Sign in to upload.');
}
}
async function handleGruuvUploadClick() {
if (isGruuvUploadRunning) return;
try {
await promptLoginIfNeeded();
} catch (error) {
setUploadStatus(`Login required: ${error?.message || 'Unknown error'}`);
return;
}
const file = musicEls.uploadFileInput?.files?.[0];
if (!file) {
setUploadStatus('Choose an audio file first.');
return;
}
isGruuvUploadRunning = true;
updateUploadUiState();
setUploadStatus(`Uploading ${file.name}...`);
try {
const result = await uploadGruuvTrack(file, {
title: musicEls.uploadTitleInput?.value?.trim() || '',
artist: musicEls.uploadArtistInput?.value?.trim() || '',
album: musicEls.uploadAlbumInput?.value?.trim() || '',
});
const title = result?.event?.tags?.find((t) => t?.[0] === 'title')?.[1] || file.name;
setUploadStatus(`Uploaded: ${title}`);
setMusicStatus(`Uploaded track to Gruuv: ${title}`, 'ok');
if (musicEls.uploadFileInput) musicEls.uploadFileInput.value = '';
} catch (error) {
console.error('[music upload] upload failed', error);
setUploadStatus(`Upload failed: ${error?.message || 'Unknown error'}`);
setMusicStatus(`Upload failed: ${error?.message || 'Unknown error'}`, 'error');
} finally {
isGruuvUploadRunning = false;
updateUploadUiState();
}
}
function resolveAuthModeFromUrl() {
const explicitAuth = getExplicitAuthModeFromUrl();
if (explicitAuth) return explicitAuth;
@@ -1701,6 +1813,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
if (musicEls.playlistNameInput) {
musicEls.playlistNameInput.disabled = isReadOnlyTargetMode();
}
updateUploadUiState();
}
async function initializeAuthentication(mode) {
@@ -1757,6 +1870,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
await initializeAuthenticatedPageFeatures();
syncLoggedInNpubToUrl();
updateViewerControls();
updateUploadUiState();
return true;
}
@@ -1850,18 +1964,10 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
return `music-queue:${currentPubkey || 'anon'}`;
}
function getAutoSaveBlossomStorageKey() {
return `music-auto-save-blossom:${currentPubkey || 'anon'}`;
}
function getSavedTrackBlossomMapStorageKey() {
return `music-saved-track-blossom-map:${currentPubkey || 'anon'}`;
}
function normalizeQueueTrack(track) {
if (!track || typeof track !== 'object') return null;
return {
id: track.id,
id: String(track.id || ''),
title: track.title || 'Unknown title',
artist: track.artist || 'Unknown artist',
duration: track.duration || 0,
@@ -1869,6 +1975,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
albumTitle: track.albumTitle || '',
albumId: track.albumId || track.album?.id || track.raw?.album?.id || '',
artistId: track.artistId || track.artist?.id || track.raw?.artist?.id || track.raw?.artists?.[0]?.id || '',
streamUrl: track.streamUrl || track.raw?.streamUrl || '',
};
}
@@ -1985,64 +2092,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
}
}
const savedTrackBlossomUrlById = new Map();
const pendingTrackBlossomSaves = new Set();
function updateSaveToggleButton() {
if (!musicEls.saveToggleBtn) return;
musicEls.saveToggleBtn.dataset.enabled = autoSaveToBlossom ? 'true' : 'false';
musicEls.saveToggleBtn.setAttribute('aria-pressed', autoSaveToBlossom ? 'true' : 'false');
musicEls.saveToggleBtn.textContent = autoSaveToBlossom ? '💾✓' : '💾';
musicEls.saveToggleBtn.title = autoSaveToBlossom
? 'Auto-save now playing track to Blossom: ON'
: 'Auto-save now playing track to Blossom: OFF';
}
function loadAutoSaveBlossomSetting() {
try {
autoSaveToBlossom = localStorage.getItem(getAutoSaveBlossomStorageKey()) === 'true';
} catch {
autoSaveToBlossom = false;
}
updateSaveToggleButton();
}
function persistAutoSaveBlossomSetting() {
try {
localStorage.setItem(getAutoSaveBlossomStorageKey(), autoSaveToBlossom ? 'true' : 'false');
} catch {
// Ignore storage write failures.
}
}
function loadSavedTrackBlossomMap() {
savedTrackBlossomUrlById.clear();
try {
const raw = localStorage.getItem(getSavedTrackBlossomMapStorageKey());
if (!raw) return;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return;
Object.entries(parsed).forEach(([trackId, url]) => {
if (!trackId || typeof url !== 'string' || !url.trim()) return;
savedTrackBlossomUrlById.set(String(trackId), url.trim());
});
} catch (error) {
console.warn('[music] Could not load saved Blossom track map:', error);
}
}
function persistSavedTrackBlossomMap() {
try {
const payload = {};
savedTrackBlossomUrlById.forEach((url, trackId) => {
payload[trackId] = url;
});
localStorage.setItem(getSavedTrackBlossomMapStorageKey(), JSON.stringify(payload));
} catch {
// Ignore storage write failures.
}
}
const mountedMusicMenus = {
myPlaylists: [],
friendPlaylists: [],
@@ -2485,6 +2534,14 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
}
function renderFriendPlaylists() {
if (!friendsFeedEnabled && !isExternalTargetMode()) {
destroyMountedMenuInstances(mountedMusicMenus.friendPlaylists);
if (musicEls.loadFriendsBtn) musicEls.loadFriendsBtn.textContent = 'Load';
musicEls.friendPlaylistsList.innerHTML = '<li class="musicPlaylistMeta">Friend playlists are paused. Click Load to fetch.</li>';
return;
}
if (musicEls.loadFriendsBtn) musicEls.loadFriendsBtn.textContent = 'Loaded';
const items = Array.from(friendPlaylists.values()).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
if (!items.length) {
destroyMountedMenuInstances(mountedMusicMenus.friendPlaylists);
@@ -2792,14 +2849,20 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
return false;
}
const exists = (playlist.tracks || []).some((item) => Number(item?.id) === Number(track?.id));
const trackId = String(track?.id || '').trim();
if (!trackId) {
if (!silent) setMusicStatus('Track is missing an id.', 'error');
return false;
}
const exists = (playlist.tracks || []).some((item) => String(item?.id || '').trim() === trackId);
if (exists) {
if (!silent) setMusicStatus('Track already in playlist.', 'neutral');
return false;
}
playlist.tracks.push({
id: track.id,
id: trackId,
title: track.title || '',
artist: track.artist || '',
duration: track.duration || 0,
@@ -3257,13 +3320,13 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
return;
}
const existingIds = new Set((playlist.tracks || []).map((track) => Number(track?.id)).filter((id) => Number.isFinite(id)));
const existingIds = new Set((playlist.tracks || []).map((track) => String(track?.id || '').trim()).filter(Boolean));
let added = 0;
let skipped = 0;
for (const track of importMatchedTracks) {
const id = Number(track?.id);
if (!Number.isFinite(id) || existingIds.has(id)) {
const id = String(track?.id || '').trim();
if (!id || existingIds.has(id)) {
skipped += 1;
continue;
}
@@ -3322,55 +3385,192 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
clearPlaylistDropIndicator();
}
function hydratePlaylistFromEvent(event, isFriend = false) {
async function resolveTracksByAddresses(addresses = []) {
const ids = Array.isArray(addresses) ? addresses.filter(Boolean) : [];
if (!ids.length) return new Map();
if (musicApi && typeof musicApi.getTracksByAddresses === 'function') {
return musicApi.getTracksByAddresses(ids);
}
console.warn('[music] Falling back to coordinate search: musicApi.getTracksByAddresses() unavailable');
const map = new Map();
for (const id of ids) {
try {
const result = await musicApi.searchTracks(id);
const items = Array.isArray(result?.items) ? result.items : [];
const match = items.find((item) => String(item?.id || '').trim() === id) || items[0] || null;
if (match) {
const prepared = musicApi.prepareTrack(match) || match;
map.set(id, prepared);
}
} catch (error) {
console.warn('[music] resolveTracksByAddresses fallback search failed', { id, error });
}
}
return map;
}
async function hydratePlaylistFromEvent(event, isFriend = false) {
const parsed = parsePlaylistEvent(event);
if (!parsed || !isMusicPlaylistEvent(event)) return;
if (isFriend) {
const key = getFriendPlaylistRouteKey(parsed);
if (key) friendPlaylists.set(key, parsed);
renderFriendPlaylists();
} else {
const existing = myPlaylists.get(parsed.identifier);
myPlaylists.set(parsed.identifier, {
identifier: parsed.identifier,
title: parsed.title,
description: parsed.description,
image: parsed.image,
banner: parsed.banner || '',
tracks: parsed.tracks,
pubkey: parsed.pubkey,
eventId: parsed.eventId,
createdAt: parsed.createdAt,
raw: parsed.raw,
...(existing || {}),
});
savePlaylistsLocal();
renderMyPlaylists();
}
const commitPlaylist = (playlist, expectedEventId = '') => {
const shouldSkipAsStale = (existing) => {
if (!existing) return false;
const existingCreatedAt = Number(existing?.createdAt || 0);
const incomingCreatedAt = Number(playlist?.createdAt || 0);
const existingEventId = String(existing?.eventId || '').trim();
const incomingEventId = String(playlist?.eventId || '').trim();
if (
expectedEventId
&& existingEventId
&& existingEventId !== expectedEventId
&& existingCreatedAt >= incomingCreatedAt
) {
return true;
}
if (
!expectedEventId
&& existingEventId
&& incomingEventId
&& existingEventId !== incomingEventId
&& existingCreatedAt > incomingCreatedAt
) {
return true;
}
return false;
};
const urlEpisode = String(getEpisodeFromUrl() || '').trim();
if (urlEpisode && parsed.identifier === urlEpisode && !selectedPlaylistId) {
if (isFriend) {
const key = getFriendPlaylistRouteKey(parsed);
if (key) openFriendPlaylistByKey(key);
const key = getFriendPlaylistRouteKey(playlist);
if (!key) return;
const existing = friendPlaylists.get(key) || null;
if (shouldSkipAsStale(existing)) return;
friendPlaylists.set(key, playlist);
renderFriendPlaylists();
} else {
openMyPlaylistByIdentifier(parsed.identifier);
}
}
const existing = myPlaylists.get(playlist.identifier) || null;
if (shouldSkipAsStale(existing)) return;
if (activePlaylistView?.scope && activePlaylistView?.playlist?.identifier === parsed.identifier) {
activePlaylistView = { ...activePlaylistView, playlist: parsed };
renderPlaylistProfileCard();
myPlaylists.set(playlist.identifier, {
...(existing || {}),
identifier: playlist.identifier,
title: playlist.title,
description: playlist.description,
image: playlist.image,
banner: playlist.banner || '',
tracks: playlist.tracks,
pubkey: playlist.pubkey,
eventId: playlist.eventId,
createdAt: playlist.createdAt,
raw: playlist.raw,
});
savePlaylistsLocal();
renderMyPlaylists();
}
const activeKey = getFriendPlaylistRouteKey(playlist);
const shouldRefreshVisibleResults = (
(musicResultsMode === 'my-playlist' && musicResultsPlaylistId === playlist.identifier)
|| (musicResultsMode === 'friend-playlist' && !!activeKey && musicResultsPlaylistId === activeKey)
);
if (shouldRefreshVisibleResults) {
musicTracks = Array.isArray(playlist.tracks) ? [...playlist.tracks] : [];
renderMusicResults(musicTracks);
}
const urlEpisode = String(getEpisodeFromUrl() || '').trim();
if (urlEpisode && playlist.identifier === urlEpisode && !selectedPlaylistId) {
if (isFriend) {
const key = getFriendPlaylistRouteKey(playlist);
if (key) openFriendPlaylistByKey(key);
} else {
openMyPlaylistByIdentifier(playlist.identifier);
}
}
if (activePlaylistView?.scope && activePlaylistView?.playlist?.identifier === playlist.identifier) {
activePlaylistView = { ...activePlaylistView, playlist };
renderPlaylistProfileCard();
}
};
commitPlaylist(parsed);
const trackIds = Array.isArray(parsed.trackIds) ? parsed.trackIds.filter(Boolean) : [];
console.log('[music] hydratePlaylistFromEvent parsed', {
identifier: parsed.identifier,
eventId: parsed.eventId,
createdAt: parsed.createdAt,
trackIdsCount: trackIds.length,
trackIds,
});
if (!trackIds.length) return;
try {
const resolvedByAddress = await resolveTracksByAddresses(trackIds);
console.log('[music] hydratePlaylistFromEvent resolved', {
identifier: parsed.identifier,
requested: trackIds.length,
resolved: resolvedByAddress?.size || 0,
});
const hydratedTracks = trackIds.map((id, index) => {
const base = parsed.tracks?.[index] || { id };
const resolved = resolvedByAddress?.get?.(id) || null;
if (resolved) {
const prepared = musicApi.prepareTrack(resolved) || resolved;
return {
...base,
...prepared,
id,
unavailable: false,
};
}
return {
...base,
id,
title: String(base?.title || '').trim() || 'Unavailable track',
artist: String(base?.artist || '').trim() || 'Unknown artist',
duration: Number(base?.duration) || 0,
cover: String(base?.cover || '').trim(),
albumTitle: String(base?.albumTitle || '').trim(),
streamUrl: String(base?.streamUrl || '').trim(),
unavailable: true,
};
});
commitPlaylist({
...parsed,
tracks: hydratedTracks,
}, parsed.eventId || '');
} catch (error) {
console.warn('[music] Failed to hydrate playlist tracks from a-tags', {
playlist: parsed.identifier,
error,
});
}
}
function subscribeAllPlaylists() {
const filter = { kinds: [PLAYLIST_KIND], '#t': ['music'], limit: 600 };
const filter = { kinds: [PLAYLIST_KIND], limit: 600 };
if (isExternalTargetMode()) {
filter.authors = [targetPubkey];
} else if (!friendsFeedEnabled && currentPubkey) {
filter.authors = [currentPubkey];
}
subscribe(
try {
playlistSubscription?.close?.();
} catch {
// ignore stale subscription handle errors
}
playlistSubscription = subscribe(
filter,
{ closeOnEose: false, cacheUsage: 'PARALLEL' }
);
@@ -3381,6 +3581,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
if (!evt || typeof evt !== 'object') return;
if (evt.kind !== PLAYLIST_KIND || !isMusicPlaylistEvent(evt)) return;
if (isExternalTargetMode() && evt.pubkey !== targetPubkey) return;
if (!friendsFeedEnabled && !isExternalTargetMode() && evt.pubkey !== currentPubkey) return;
const treatAsMine = Boolean(
currentPubkey
@@ -3502,7 +3703,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
updatePlayingResultHighlight();
saveQueueLocal();
setMusicStatus(`Playing queue: ${track.title}`, 'ok');
void maybeAutoSaveTrackToBlossom(track);
} catch (error) {
console.error(error);
setMusicStatus(`Queue playback failed: ${error.message || 'Unknown error'}`, 'error');
@@ -4422,14 +4622,70 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
async function fetchDownloadBlobForTrack(track) {
if (!track?.id) throw new Error('Track not available for download.');
const stream = await musicApi.getTrackStream(track.id, 'LOSSLESS');
const streamUrl = stream?.streamUrl;
if (!streamUrl) throw new Error('No stream URL for download');
const res = await fetch(streamUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const ext = extensionFromMime(blob.type);
return { blob, ext };
console.debug('[music][download] start', {
trackId: track.id,
title: track.title || '',
});
const qualityCandidates = ['DIRECT'];
let lastError = null;
for (const quality of qualityCandidates) {
try {
console.debug('[music][download] requesting stream', { trackId: track.id, quality });
const stream = await musicApi.getTrackStream(track.id);
const streamUrl = stream?.streamUrl;
if (!streamUrl) {
lastError = new Error(`No stream URL for quality ${quality}`);
console.warn('[music][download] missing stream URL', { trackId: track.id, quality });
continue;
}
console.debug('[music][download] fetching stream URL', {
trackId: track.id,
quality,
streamUrl,
});
const res = await fetch(streamUrl);
if (!res.ok) {
lastError = new Error(`HTTP ${res.status} for quality ${quality}`);
console.warn('[music][download] fetch failed', {
trackId: track.id,
quality,
status: res.status,
statusText: res.statusText,
});
continue;
}
const blob = await res.blob();
const ext = extensionFromMime(blob.type);
console.debug('[music][download] success', {
trackId: track.id,
quality,
mimeType: blob.type,
size: blob.size,
ext,
});
return { blob, ext };
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error || 'Unknown error'));
console.warn('[music][download] quality attempt threw', {
trackId: track.id,
quality,
error: lastError.message,
});
}
}
console.error('[music][download] all quality attempts failed', {
trackId: track.id,
error: lastError?.message || 'No stream URL for download',
});
throw (lastError || new Error('No stream URL for download'));
}
async function downloadTrackFile(track, { suppressStatus = false } = {}) {
@@ -4458,65 +4714,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
}
}
async function saveTrackToBlossom(track, { suppressStatus = true } = {}) {
const trackId = String(track?.id || '').trim();
if (!trackId) return '';
if (savedTrackBlossomUrlById.has(trackId)) {
return savedTrackBlossomUrlById.get(trackId) || '';
}
if (pendingTrackBlossomSaves.has(trackId)) return '';
const targetServer = getDefaultBlossomServer();
if (!targetServer) {
if (!suppressStatus) setMusicStatus('No Blossom server configured.', 'error');
return '';
}
pendingTrackBlossomSaves.add(trackId);
try {
if (!suppressStatus) {
setMusicStatus(`Saving to Blossom: ${track.title || 'Track'}...`, 'neutral');
}
const { blob, ext } = await fetchDownloadBlobForTrack(track);
const title = sanitizeFilenamePart(track.title || 'Unknown title') || 'Unknown title';
const artist = sanitizeFilenamePart(track.artist || 'Unknown artist') || 'Unknown artist';
const fileName = `${artist} - ${title}.${ext}`;
const file = new File([blob], fileName, {
type: blob.type || 'application/octet-stream',
});
const result = await uploadToServer(file, targetServer);
const sha256 = String(result?.sha256 || '').trim();
if (!sha256) throw new Error('Upload returned no SHA-256 hash.');
const suffix = ext ? `.${ext.replace(/^\./, '')}` : '';
const blossomUrl = `${String(result?.serverUrl || targetServer).replace(/\/$/, '')}/${sha256}${suffix}`;
savedTrackBlossomUrlById.set(trackId, blossomUrl);
persistSavedTrackBlossomMap();
if (!suppressStatus) {
setMusicStatus(`Saved to Blossom: ${track.title || 'Track'}`, 'ok');
}
return blossomUrl;
} catch (error) {
console.error('[music] saveTrackToBlossom failed:', error);
if (!suppressStatus) {
setMusicStatus(`Save to Blossom failed: ${error.message || 'Unknown error'}`, 'error');
}
return '';
} finally {
pendingTrackBlossomSaves.delete(trackId);
}
}
async function maybeAutoSaveTrackToBlossom(track) {
if (!autoSaveToBlossom) return;
await saveTrackToBlossom(track, { suppressStatus: true });
}
async function downloadMusicByIndex(index) {
if (!Number.isInteger(index) || index < 0 || index >= musicTracks.length) return;
const track = musicTracks[index];
@@ -4667,6 +4864,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
installImageFallbacks();
initMusicResizableColumns();
updateUploadUiState();
musicPlayer.onTrackChanged = (track) => {
updateMusicPlayerMeta(track);
@@ -4690,6 +4888,10 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
applyTargetPubkeyFromInput();
});
musicEls.uploadBtn?.addEventListener('click', async () => {
await handleGruuvUploadClick();
});
musicEls.results.addEventListener('click', async (event) => {
const playAlbumNow = event.target.closest('[data-play-album-now]');
@@ -4916,6 +5118,15 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
navigate(`/playlist/${encodeURIComponent(pubkey)}/${encodeURIComponent(identifier)}`);
});
musicEls.loadFriendsBtn?.addEventListener('click', () => {
if (isExternalTargetMode()) return;
if (friendsFeedEnabled) return;
friendsFeedEnabled = true;
setMusicStatus('Loading friend playlists…', 'neutral');
renderFriendPlaylists();
subscribeAllPlaylists();
});
musicEls.playlistNameInput?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
@@ -5105,16 +5316,9 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
updatePlayingResultHighlight();
});
musicEls.playbackModeBtn?.addEventListener('click', cyclePlaybackMode);
musicEls.saveToggleBtn?.addEventListener('click', () => {
autoSaveToBlossom = !autoSaveToBlossom;
persistAutoSaveBlossomSetting();
updateSaveToggleButton();
setMusicStatus(`Auto-save to Blossom ${autoSaveToBlossom ? 'enabled' : 'disabled'}.`, 'ok');
});
setMusicPlayButtonState();
updateMusicPlayerMeta(null);
updatePlaybackModeButton();
updateSaveToggleButton();
musicEls.queueClearBtn?.addEventListener('click', clearQueue);
@@ -5215,14 +5419,17 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
myPlaylists.clear();
}
loadQueueLocal();
loadAutoSaveBlossomSetting();
loadSavedTrackBlossomMap();
updateViewerControls();
friendsFeedEnabled = isExternalTargetMode();
if (musicEls.loadFriendsBtn) {
musicEls.loadFriendsBtn.disabled = isExternalTargetMode();
musicEls.loadFriendsBtn.textContent = isExternalTargetMode() ? 'Auto' : 'Load';
}
renderMyPlaylists();
renderFriendPlaylists();
renderQueue();
subscribeAllPlaylists();
window.addEventListener('ndkEvent', handleMusicNdkEvent);
subscribeAllPlaylists();
if (!window.location.hash) {
const episodeRoute = getEpisodeRouteFromUrlContext();
+77 -15
View File
@@ -1179,6 +1179,7 @@ function startRelayHealthCheck() {
class MessageBasedSigner {
constructor() {
this.pubkey = null;
this.signedEventsBySig = new Map(); // sig -> signed raw event from signer page
}
async user() {
@@ -1196,12 +1197,59 @@ class MessageBasedSigner {
async sign(event) {
console.log('[Worker] MessageBasedSigner: Signing event kind:', event.kind);
// Request signing from page
const signedEvent = await this.requestFromPage('signEvent', { event });
const sig = String(signedEvent?.sig || '').trim();
if (!sig) {
throw new Error('signEvent returned no signature');
}
// Keep a snapshot so caller can align NDKEvent fields to exactly what was signed.
const snapshot = {
id: String(signedEvent?.id || ''),
pubkey: String(signedEvent?.pubkey || ''),
created_at: Number(signedEvent?.created_at || 0),
kind: Number(signedEvent?.kind),
content: String(signedEvent?.content || ''),
tags: Array.isArray(signedEvent?.tags)
? signedEvent.tags.map((tag) => Array.isArray(tag) ? tag.map((v) => String(v ?? '')) : [String(tag ?? '')])
: [],
sig
};
this.signedEventsBySig.set(sig, snapshot);
if (this.signedEventsBySig.size > 200) {
const oldestKey = this.signedEventsBySig.keys().next().value;
if (oldestKey) this.signedEventsBySig.delete(oldestKey);
}
console.log('[Worker] MessageBasedSigner: Event signed');
return signedEvent.sig;
return sig;
}
applySignedSnapshotToEvent(ndkEvent) {
if (!ndkEvent) return false;
const sig = String(ndkEvent?.sig || '').trim();
if (!sig) return false;
const snapshot = this.signedEventsBySig.get(sig);
if (!snapshot) return false;
this.signedEventsBySig.delete(sig);
if (snapshot.id) ndkEvent.id = snapshot.id;
if (snapshot.pubkey) ndkEvent.pubkey = snapshot.pubkey;
if (Number.isFinite(snapshot.created_at) && snapshot.created_at > 0) ndkEvent.created_at = snapshot.created_at;
if (Number.isFinite(snapshot.kind)) ndkEvent.kind = snapshot.kind;
ndkEvent.content = String(snapshot.content || '');
ndkEvent.tags = Array.isArray(snapshot.tags)
? snapshot.tags.map((tag) => Array.isArray(tag) ? tag.map((v) => String(v ?? '')) : [String(tag ?? '')])
: [];
ndkEvent.sig = snapshot.sig;
return true;
}
async encrypt(recipient, plaintext) {
@@ -1307,6 +1355,16 @@ class MessageBasedSigner {
// Create message-based signer instance
let messageSigner = null;
async function signEventWithMessageSigner(event, opts) {
if (!event) throw new Error('Missing event to sign');
await event.sign(messageSigner, opts);
try {
messageSigner?.applySignedSnapshotToEvent?.(event);
} catch (error) {
console.warn('[Worker] Failed applying signed snapshot to event:', error?.message || error);
}
}
// Initialize NDK with Dexie cache and message-based signer
async function initNDK() {
console.log('[Worker] Initializing NDK...');
@@ -2662,7 +2720,7 @@ async function publishDirectProofs(debugContext = null) {
});
log('sign 7375 start', { mintUrl });
await tokenEvent.sign(messageSigner);
await signEventWithMessageSigner(tokenEvent);
log('sign 7375 done', { mintUrl });
log('publish 7375 start', { mintUrl });
@@ -2687,7 +2745,7 @@ async function publishDirectProofs(debugContext = null) {
content: '',
created_at: Math.floor(Date.now() / 1000)
});
await deletionEvent.sign(messageSigner);
await signEventWithMessageSigner(deletionEvent);
await deletionEvent.publish();
}
log('delete old 7375 done');
@@ -2726,7 +2784,7 @@ async function publishDirectProofs(debugContext = null) {
});
log('sign 17375 start');
await walletEvent.sign(messageSigner);
await signEventWithMessageSigner(walletEvent);
log('sign 17375 done');
log('publish 17375 start');
@@ -2750,7 +2808,7 @@ async function publishDirectProofs(debugContext = null) {
content: '',
created_at: Math.floor(Date.now() / 1000)
});
await deletionEvent.sign(messageSigner);
await signEventWithMessageSigner(deletionEvent);
await deletionEvent.publish();
}
log('delete old 17375 done');
@@ -2951,7 +3009,7 @@ async function publishSpendingHistoryEvent(entry = {}) {
created_at: Math.floor(Date.now() / 1000)
});
await historyEvent.sign(messageSigner);
await signEventWithMessageSigner(historyEvent);
const relaySet = await historyEvent.publish();
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
@@ -3003,7 +3061,7 @@ async function handleWalletPublishMintList(requestId, relays, receiveMints, port
mintListEvent.mints = mints;
mintListEvent.relays = relayUrls;
if (p2pk) mintListEvent.p2pk = p2pk;
await mintListEvent.sign(messageSigner);
await signEventWithMessageSigner(mintListEvent);
const relaySet = await mintListEvent.publishReplaceable();
eventId = mintListEvent.id || null;
publishedRelayCount = relaySet ? relaySet.size : 0;
@@ -3019,7 +3077,7 @@ async function handleWalletPublishMintList(requestId, relays, receiveMints, port
content: '',
created_at: Math.floor(Date.now() / 1000)
});
await mintListEvent.sign(messageSigner);
await signEventWithMessageSigner(mintListEvent);
const relaySet = await mintListEvent.publish();
eventId = mintListEvent.id || null;
publishedRelayCount = relaySet ? relaySet.size : 0;
@@ -3233,7 +3291,7 @@ async function handleWalletSendNutzap(
created_at: Math.floor(Date.now() / 1000)
});
await nutzapEvent.sign(messageSigner);
await signEventWithMessageSigner(nutzapEvent);
let relaySet = null;
if (recipientRelayUrls.length > 0 && NDKRelaySet?.fromRelayUrls) {
@@ -4256,7 +4314,7 @@ async function publishUserSettingsNow() {
created_at: now
});
await ndkEvent.sign(messageSigner);
await signEventWithMessageSigner(ndkEvent);
const relaySet = await ndkEvent.publish();
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
@@ -5304,7 +5362,7 @@ async function handlePublish(requestId, event, port) {
// Sign using message-based signer (will request signing from page)
console.log('[Worker] Requesting signature from page...');
await ndkEvent.sign(messageSigner);
await signEventWithMessageSigner(ndkEvent);
console.log('[Worker] Event signed, publishing to relays...');
// Publish to relays
@@ -6103,10 +6161,8 @@ self.onconnect = (event) => {
console.log('[Worker] New port connected');
connectedPorts.push(port);
activeSigningPort = port;
port.onmessage = async (e) => {
activeSigningPort = port;
const {
type,
pubkey,
@@ -6180,6 +6236,12 @@ self.onconnect = (event) => {
case 'ndkFetchEvents':
await handleNdkFetchEvents(requestId, filters, port);
break;
case 'setActiveSigningPort':
activeSigningPort = port;
console.log('[Worker] Active signing port set explicitly by page');
port.postMessage({ type: 'activeSigningPortSet' });
break;
case 'signResponse':
handleSignResponse(requestId, result, error);
-4401
View File
File diff suppressed because it is too large Load Diff
+274 -23
View File
@@ -253,6 +253,24 @@
#divHeaderText {
outline: none;
}
#txtDecryptTrace {
font-family: var(--font-mono);
white-space: pre-wrap;
word-break: break-word;
}
#divDiagnosticBadge {
display: inline-block;
margin-left: 10px;
padding: 4px 8px;
border: 1px solid #b45309;
border-radius: 4px;
color: #b45309;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
</style>
<script src="./js/marked.min.js"></script>
</head>
@@ -330,10 +348,15 @@
<td class="tdTitle">Autosave</td>
<td id="txtAutosave" class="tdEditable" contenteditable="true">10</td>
</tr>
<tr>
<td class="tdTitle">Decrypt Trace</td>
<td id="txtDecryptTrace">None</td>
</tr>
<tr>
<td class="tdTitle"></td>
<td>
<button id="btnSaveNote" class="btn">Save Note</button>
<span id="divDiagnosticBadge" class="clsHidden">DIAGNOSTIC VIEW — do not save</span>
</td>
</tr>
</table>
@@ -413,7 +436,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
@@ -449,10 +472,13 @@ const versionInfo = await getVersion();
let OBJ_NOTES = {};
let CURRENT_NOTE = "";
let ENCRYPTED_TAG_TYPES = ["title", "image", "summary", "t"];
const MAX_DECRYPT_PASSES = 8;
let EDIT_MODE = false;
let LastText = "";
let NUM_LAST_EDIT_TIME = Math.floor(Date.now() / 1000);
let numAutoSaveSec = 30;
let CURRENT_DECRYPT_TRACE = null;
let DIAGNOSTIC_ACTIVE = false;
/* ================================================================
DOM VARIABLES
@@ -478,6 +504,8 @@ const versionInfo = await getVersion();
const divHeaderText = document.getElementById('divHeaderText');
const tblTags = document.getElementById('tblTags');
const divFiles = document.getElementById('divFiles');
const txtDecryptTrace = document.getElementById('txtDecryptTrace');
const divDiagnosticBadge = document.getElementById('divDiagnosticBadge');
/* ================================================================
HAMBURGER MENU
@@ -549,6 +577,195 @@ const versionInfo = await getVersion();
/* ================================================================
NOTE FUNCTIONS
================================================================ */
async function nip44DecryptFromSelf(ciphertext) {
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
if (!selfPubkey) throw new Error('Missing pubkey for NIP-44 decryption');
if (!window.nostr?.nip44?.decrypt) throw new Error('NIP-44 decrypt unavailable');
return await window.nostr.nip44.decrypt(selfPubkey, String(ciphertext || ''));
}
async function nip04DecryptFromSelf(ciphertext) {
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
if (!selfPubkey) throw new Error('Missing pubkey for NIP-04 decryption');
if (!window.nostr?.nip04?.decrypt) throw new Error('NIP-04 decrypt unavailable');
return await window.nostr.nip04.decrypt(selfPubkey, String(ciphertext || ''));
}
function looksLikeNip04Ciphertext(input) {
const text = String(input || '').trim();
return /^[A-Za-z0-9+/_-]+={0,2}\?iv=[A-Za-z0-9+/_-]+={0,2}$/.test(text);
}
function looksLikeBase64(input) {
const text = String(input || '').trim();
if (!text || text.length < 24 || text.length % 4 !== 0) return false;
return /^[A-Za-z0-9+/]+={0,2}$/.test(text);
}
function looksLikeNip44Ciphertext(input) {
const text = String(input || '').trim();
if (!looksLikeBase64(text)) return false;
try {
const decoded = atob(text);
if (!decoded || decoded.length < 2) return false;
const versionByte = decoded.charCodeAt(0);
return versionByte === 1 || versionByte === 2;
} catch (_error) {
return false;
}
}
function classifyCiphertext(input) {
const text = String(input || '').trim();
if (!text) return 'plaintext';
if (looksLikeNip04Ciphertext(text)) return 'nip04';
if (looksLikeNip44Ciphertext(text)) return 'nip44';
if (/[\n\r\t]/.test(text) || /[#*_`\[\]{}()]/.test(text)) {
return 'plaintext';
}
if (looksLikeBase64(text)) return 'base64-unknown';
return 'plaintext';
}
async function decryptOnePass(input) {
const text = String(input || '').trim();
const kind = classifyCiphertext(text);
const methods = kind === 'nip04'
? ['nip04', 'nip44']
: ['nip44', 'nip04'];
for (const method of methods) {
try {
const output = method === 'nip44'
? await nip44DecryptFromSelf(text)
: await nip04DecryptFromSelf(text);
return {
ok: true,
method,
text: String(output || '')
};
} catch (_error) {
// try next method
}
}
return {
ok: false,
method: null,
text
};
}
async function peelDecrypt(input, maxPasses = MAX_DECRYPT_PASSES) {
let current = String(input || '').trim();
const trace = [];
let stoppedReason = 'max-passes';
for (let pass = 1; pass <= maxPasses; pass++) {
const beforeClass = classifyCiphertext(current);
if (beforeClass === 'plaintext') {
stoppedReason = 'plaintext';
break;
}
const one = await decryptOnePass(current);
if (!one.ok) {
stoppedReason = 'decrypt-failed';
break;
}
trace.push({
pass,
method: one.method,
inLength: current.length,
outLength: one.text.length
});
current = String(one.text || '');
}
return {
finalText: current,
trace,
stoppedReason,
finalClass: classifyCiphertext(current)
};
}
function formatDecryptTrace(diag) {
if (!diag) return 'None';
const passes = Number(diag.passes || 0);
const methods = Array.isArray(diag.methods) && diag.methods.length > 0
? diag.methods.join(' → ')
: 'none';
const reason = String(diag.stoppedReason || 'unknown');
const finalClass = String(diag.finalClass || 'unknown');
const finalLength = Number(diag.finalLength || 0);
return `passes=${passes} | methods=${methods} | stop=${reason} | final=${finalClass} | len=${finalLength}`;
}
async function peelTagsMaybeEncrypted(objTags) {
const safe = objTags && typeof objTags === 'object' ? { ...objTags } : {};
for (const key of ['title', 'image', 'summary']) {
const value = String(safe[key] || '').trim();
if (!value) continue;
const result = await peelDecrypt(value, 3);
safe[key] = result.trace.length > 0 ? result.finalText : value;
}
if (Array.isArray(safe.t)) {
const decryptedTags = [];
for (const tagValue of safe.t) {
const value = String(tagValue || '').trim();
if (!value) continue;
const result = await peelDecrypt(value, 3);
decryptedTags.push(result.trace.length > 0 ? result.finalText : value);
}
safe.t = decryptedTags;
}
return safe;
}
async function ensureNoteDiagnostics(note) {
if (!note || Number(note.kind) !== 30024) return note;
if (note.decryptDiagnostics) return note;
const peel = await peelDecrypt(String(note.content || ''));
const decryptedTags = await peelTagsMaybeEncrypted(note.objTags || {});
note.decryptedContent = peel.finalText;
note.decryptedObjTags = decryptedTags;
note.decryptDiagnostics = {
passes: peel.trace.length,
methods: peel.trace.map((step) => String(step.method || '').toUpperCase()),
stoppedReason: peel.stoppedReason,
finalClass: peel.finalClass,
finalLength: String(peel.finalText || '').length
};
return note;
}
function setDiagnosticUI(note) {
CURRENT_DECRYPT_TRACE = note?.decryptDiagnostics || null;
txtDecryptTrace.textContent = formatDecryptTrace(CURRENT_DECRYPT_TRACE);
DIAGNOSTIC_ACTIVE = Boolean(
Number(note?.kind) === 30024 &&
(Number(CURRENT_DECRYPT_TRACE?.passes || 0) > 0 || CURRENT_DECRYPT_TRACE?.stoppedReason === 'decrypt-failed')
);
if (DIAGNOSTIC_ACTIVE) {
divDiagnosticBadge.className = '';
} else {
divDiagnosticBadge.className = 'clsHidden';
}
}
const LoadSidenav = async () => {
// Subscribe to long-form notes (kind 30023 and 30024)
const notesSub = subscribe(
@@ -561,8 +778,8 @@ const versionInfo = await getVersion();
// Get notes from cache via events
let htmlOut = `<table id="tblNotes">`;
htmlOut += `<tr><th colspan="8"> NOTES </th></tr>`;
htmlOut += `<tr><th colspan="8"> &nbsp </th></tr>`;
htmlOut += `<tr><th colspan="10"> NOTES </th></tr>`;
htmlOut += `<tr><th colspan="10"> &nbsp </th></tr>`;
htmlOut += `<tr>
<th>Evt</th>
<th>Del</th>
@@ -573,21 +790,26 @@ const versionInfo = await getVersion();
<th>Edited</th>
<th>Type</th>
<th>Encrypted</th>
<th>Layers</th>
</tr>`;
// Build table from OBJ_NOTES
for (let Each of Object.keys(OBJ_NOTES).sort((a, b) => b - a)) {
const note = OBJ_NOTES[Each];
if (Number(note?.kind) === 30024) {
await ensureNoteDiagnostics(note);
}
let d = new Date(Number(Each) * 1000);
let dCreated = d.toLocaleString();
d = new Date(Number(OBJ_NOTES[Each].created_at) * 1000);
d = new Date(Number(note?.created_at) * 1000);
let dEdited = d.toLocaleString();
const dTagValue = OBJ_NOTES[Each].objTags?.d ?? `${OBJ_NOTES[Each].created_at} (from created_at)`;
const dTagValue = note?.objTags?.d ?? `${note?.created_at} (from created_at)`;
const rowTitle = note?.decryptedObjTags?.title || note?.objTags?.title || 'Untitled';
let Status = "Published";
let svgPub = `<div class="divTableButtons"></div>`;
if (OBJ_NOTES[Each].kind == 30024) {
Status = "Encrypted";
if (note?.kind == 30024) {
svgPub = `<div id="divSVGPublish-${Each}" class="divTableButtons"></div>`;
}
@@ -595,12 +817,13 @@ const versionInfo = await getVersion();
<td class="col5"> <div id="divSVG-${Each}" class="divTableButtons">📋</div> </td>
<td class="col5"> <div id="divSVGDelete-${Each}" class="divTableButtons"></div> </td>
<td class="col5"> ${svgPub} </td>
<td class="col0" id="divFileID-${Each}">${OBJ_NOTES[Each].objTags?.title || 'Untitled'}</td>
<td class="col0" id="divFileID-${Each}">${rowTitle}</td>
<td class="col1">${dTagValue}</td>
<td class="col1">${dCreated}</td>
<td class="col1">${dEdited}</td>
<td class="col3">${OBJ_NOTES[Each].kind} </td>
<td class="col3">${OBJ_NOTES[Each].encrypted ? 'Yes' : 'No'} </td>
<td class="col3">${note?.kind} </td>
<td class="col3">${note?.encrypted ? 'Yes' : 'No'} </td>
<td class="col3">${note?.kind == 30024 ? Number(note?.decryptDiagnostics?.passes || 0) : '-'}</td>
</tr>`;
}
@@ -656,9 +879,14 @@ const versionInfo = await getVersion();
// Save when switching from edit to view mode if there are unsaved changes
if (taNote.value.trim() !== LastText) {
console.log('[autosave] Switching to view mode with unsaved changes - triggering save');
await Publish30024Note();
LastText = taNote.value.trim();
if (DIAGNOSTIC_ACTIVE) {
console.log('[autosave] Diagnostic view active - skipping implicit save');
LastText = taNote.value.trim();
} else {
console.log('[autosave] Switching to view mode with unsaved changes - triggering save');
await Publish30024Note();
LastText = taNote.value.trim();
}
}
}
};
@@ -672,15 +900,24 @@ const versionInfo = await getVersion();
return;
}
taNote.value = OBJ_NOTES[id].content || '';
LastText = OBJ_NOTES[id].content || '';
txtTitle.textContent = OBJ_NOTES[id].objTags?.title || '';
divTitle.textContent = OBJ_NOTES[id].objTags?.title || '';
txtSummary.textContent = OBJ_NOTES[id].objTags?.summary || '';
txtTags.textContent = OBJ_NOTES[id].objTags?.t?.join(' ') || '';
const note = OBJ_NOTES[id];
await ensureNoteDiagnostics(note);
const displayTags = note.decryptedObjTags || note.objTags || {};
const displayContent = Number(note.kind) === 30024
? String(note.decryptedContent || note.content || '')
: String(note.content || '');
taNote.value = displayContent;
LastText = taNote.value.trim();
txtTitle.textContent = displayTags?.title || '';
divTitle.textContent = displayTags?.title || '';
txtSummary.textContent = displayTags?.summary || '';
txtTags.textContent = displayTags?.t?.join(' ') || '';
CURRENT_NOTE = id;
txtID.textContent = CURRENT_NOTE;
setDiagnosticUI(note);
// Parse markdown for preview
console.log('[note.html] LoadNote - window.marked exists:', !!window.marked);
@@ -699,11 +936,11 @@ const versionInfo = await getVersion();
console.log('[note.html] No content to display - reason:', reason);
}
if (OBJ_NOTES[id].objTags?.image === "" || !OBJ_NOTES[id].objTags?.image) {
if (displayTags?.image === "" || !displayTags?.image) {
txtImg.textContent = "";
imgMain.className = "clsHidden";
} else {
txtImg.textContent = OBJ_NOTES[id].objTags.image;
txtImg.textContent = displayTags.image;
imgMain.className = "clsVisible";
}
@@ -932,7 +1169,7 @@ const versionInfo = await getVersion();
);
// Listen for incoming note events
window.addEventListener('ndkEvent', (event) => {
window.addEventListener('ndkEvent', async (event) => {
const evt = event.detail;
if (evt.kind === 30023 || evt.kind === 30024) {
console.log("[note.html] Received note:", evt);
@@ -957,6 +1194,14 @@ const versionInfo = await getVersion();
objTags: objTags,
encrypted: evt.kind === 30024
};
if (evt.kind === 30024) {
await ensureNoteDiagnostics(OBJ_NOTES[dTag]);
}
if (isNavOpen) {
LoadSidenav();
}
}
});
@@ -982,6 +1227,7 @@ const versionInfo = await getVersion();
txtSummary.textContent = "";
txtTags.textContent = "";
divHeaderText.textContent = "TITLE";
setDiagnosticUI(null);
SetEditMode(true);
closeNav();
});
@@ -1042,6 +1288,11 @@ const versionInfo = await getVersion();
return;
}
if (DIAGNOSTIC_ACTIVE) {
console.log('[autosave] Skipping: diagnostic mode active');
return;
}
// Skip if not enough time passed since last edit
const secondsSinceEdit = Math.floor(Date.now() / 1000) - NUM_LAST_EDIT_TIME;
// console.log('[autosave] Seconds since edit:', secondsSinceEdit, '| required:', autoSaveSetting);
+1 -1
View File
@@ -356,7 +356,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -161,7 +161,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
<script type="module">
+1 -1
View File
@@ -145,7 +145,7 @@
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./ndk-core.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
<script type="module">
+1 -1
View File
@@ -378,7 +378,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -301,7 +301,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getRelayData, fetchEventsFromAllRelays, ndkFetchEvents, queryCache } from './js/init-ndk.mjs';
+1 -1
View File
@@ -169,7 +169,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -157,7 +157,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -1015,7 +1015,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -325,7 +325,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -323,7 +323,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -253,7 +253,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -202,7 +202,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -268,7 +268,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
+1399
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -514,7 +514,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -326,7 +326,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -639,7 +639,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -331,7 +331,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
+1 -1
View File
@@ -255,7 +255,7 @@ note("<c a f e>(3,8)")
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -144,7 +144,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -305,7 +305,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -508,7 +508,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+1 -1
View File
@@ -307,7 +307,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
+2 -1
View File
@@ -1535,6 +1535,7 @@
<div id="streamingSiteFormPanel" class="hidden" style="margin-top:6px;">
<input id="inputSiteName" class="streamInput" placeholder="Site name" />
<input id="inputSiteStreamBaseUrl" class="streamInput" placeholder="Stream base URL (https://example.com)" />
<input id="inputSiteStreamUrlTemplate" class="streamInput" placeholder="Stream URL template ({base}/stream/{slug}/stream.m3u8)" />
<input id="inputSiteRtmpServer" class="streamInput" placeholder="RTMP server (rtmp://example.com:1935/publish)" />
<input id="inputSiteObsKeyTemplate" class="streamInput" placeholder="OBS key template ({slug}/src/{SECRET_KEY})" />
<div style="display:flex; gap:4px; flex-wrap:wrap; margin-top:4px;">
@@ -1905,7 +1906,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================