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();