Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fed0c818 | ||
|
|
0c9e8bebcb | ||
|
|
22334ad432 | ||
|
|
3ff4bc532b |
@@ -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.
|
||||
+4291
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.4",
|
||||
"VERSION_NUMBER": "0.7.4",
|
||||
"BUILD_DATE": "2026-04-19T18:00:32.861Z"
|
||||
"VERSION": "v0.7.8",
|
||||
"VERSION_NUMBER": "0.7.8",
|
||||
"BUILD_DATE": "2026-04-28T14:39:51.733Z"
|
||||
}
|
||||
|
||||
+64
-157
@@ -932,15 +932,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;
|
||||
@@ -1226,7 +1217,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>
|
||||
@@ -1377,8 +1367,6 @@
|
||||
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 { SimplePlayer } from './js/greyscale-player.mjs';
|
||||
@@ -1486,7 +1474,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'),
|
||||
@@ -1543,7 +1530,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
};
|
||||
let isHandlingRoute = false;
|
||||
let playbackMode = 'normal';
|
||||
let autoSaveToBlossom = false;
|
||||
let currentPlayingTrackId = '';
|
||||
let pendingShareUrl = '';
|
||||
let pendingShareArtUrl = '';
|
||||
@@ -1850,14 +1836,6 @@ 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 {
|
||||
@@ -1985,64 +1963,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: [],
|
||||
@@ -3502,7 +3422,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 +4341,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 = ['HIGH', 'MP3_320', 'LOSSLESS'];
|
||||
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, quality);
|
||||
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 +4433,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];
|
||||
@@ -5105,16 +5021,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,8 +5124,6 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
myPlaylists.clear();
|
||||
}
|
||||
loadQueueLocal();
|
||||
loadAutoSaveBlossomSetting();
|
||||
loadSavedTrackBlossomMap();
|
||||
updateViewerControls();
|
||||
renderMyPlaylists();
|
||||
renderFriendPlaylists();
|
||||
|
||||
Reference in New Issue
Block a user