diff --git a/www/js/version.json b/www/js/version.json index 42ae73f..788bb15 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.3", - "VERSION_NUMBER": "0.7.3", - "BUILD_DATE": "2026-04-19T17:43:13.562Z" + "VERSION": "v0.7.4", + "VERSION_NUMBER": "0.7.4", + "BUILD_DATE": "2026-04-19T18:00:32.861Z" } diff --git a/www/music.html b/www/music.html index 46cd46e..007c011 100644 --- a/www/music.html +++ b/www/music.html @@ -932,6 +932,15 @@ 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; @@ -1217,6 +1226,7 @@
+ @@ -1367,6 +1377,7 @@ 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'; @@ -1475,6 +1486,7 @@ 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'), @@ -1531,6 +1543,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; }; let isHandlingRoute = false; let playbackMode = 'normal'; + let autoSaveToBlossom = false; let currentPlayingTrackId = ''; let pendingShareUrl = ''; let pendingShareArtUrl = ''; @@ -1837,6 +1850,14 @@ 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 { @@ -1964,6 +1985,64 @@ 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: [], @@ -3423,6 +3502,7 @@ 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'); @@ -4378,6 +4458,65 @@ 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]; @@ -4966,9 +5105,16 @@ 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); @@ -5069,6 +5215,8 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs'; myPlaylists.clear(); } loadQueueLocal(); + loadAutoSaveBlossomSetting(); + loadSavedTrackBlossomMap(); updateViewerControls(); renderMyPlaylists(); renderFriendPlaylists();