feat: add playlist cover generation and service worker updates

- Add database migration for `generated_playlist_covers` table to track playlist cover jobs
- Implement backend routes for generating, polling, and applying playlist cover art via ComfyUI
- Add `comfyui_node_image_seed` setting to allow configurable seeds for image generation
- Update UI to show "Generate Cover Art" link on playlist forms when image generation is enabled
- Add service worker update notification banner with refresh and dismiss options
- Enhance player with push notifications for now-playing updates
- Update service worker to handle notification clicks and improve caching strategy
This commit is contained in:
Brian Fertig 2026-04-18 09:18:03 -06:00
parent 8170cfb7a2
commit a4954dfaa8
15 changed files with 436 additions and 18 deletions

View File

@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS generated_playlist_covers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
playlist_id INTEGER NOT NULL,
job_id TEXT,
status TEXT NOT NULL DEFAULT 'pending',
image_path TEXT,
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS generated_playlist_covers_playlist
ON generated_playlist_covers (user_id, playlist_id, created_at DESC);

View File

@ -0,0 +1 @@
ALTER TABLE site_settings ADD COLUMN comfyui_node_image_seed TEXT;

View File

@ -2052,6 +2052,44 @@ hr.hr-neon {
.nav .btn-small { display: block; width: 100%; text-align: center; margin: 0.25rem 0; }
}
/* ---------- SW update banner ---------- */
#sw-update-banner {
position: fixed;
bottom: 5rem;
left: 50%;
transform: translateX(-50%);
z-index: 999;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
border-radius: var(--radius);
background: rgba(255, 43, 214, 0.10);
border: 1px solid var(--neon-magenta);
box-shadow: 0 0 14px rgba(255, 43, 214, 0.4);
font-family: 'Orbitron', sans-serif;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--neon-magenta);
white-space: nowrap;
}
#sw-update-banner button {
background: none;
border: 1px solid var(--neon-magenta);
color: var(--neon-magenta);
border-radius: var(--radius);
padding: 0.2rem 0.6rem;
font-family: inherit;
font-size: inherit;
letter-spacing: inherit;
cursor: pointer;
}
#sw-update-banner button:first-of-type {
background: var(--neon-magenta);
color: #000;
}
/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
.flash { animation: none; }

View File

@ -71,6 +71,29 @@
});
}
function requestNotificationPermission() {
if (!('Notification' in window) || Notification.permission !== 'default') return;
Notification.requestPermission();
}
function showPlayNotification(song) {
if (document.hasFocus()) return;
if (!('Notification' in window) || Notification.permission !== 'granted') return;
if (!('serviceWorker' in navigator)) return;
const icon = song.cover
? new URL(song.cover, window.location.origin).href
: '/android-chrome-192x192.png';
navigator.serviceWorker.ready.then(function (reg) {
reg.showNotification(song.title, {
body: song.artist || '',
icon: icon,
badge: '/android-chrome-192x192.png',
tag: 'now-playing',
silent: true,
});
});
}
function updateMediaSession() {
if (!('mediaSession' in navigator)) return;
const cur = state.queue[state.index];
@ -99,9 +122,11 @@
render();
highlightNowPlaying(cur.id);
updateMediaSession();
showPlayNotification(cur);
}
function playOne(song) {
requestNotificationPermission();
state.queue = [song];
state.index = 0;
playCurrent();
@ -109,6 +134,7 @@
function playList(list) {
if (!Array.isArray(list) || list.length === 0) return;
requestNotificationPermission();
state.queue = list;
state.index = 0;
window.dispatchEvent(new CustomEvent('briTunes:queue', { detail: { queue: list.slice() } }));

View File

@ -1,4 +1,4 @@
const CACHE = 'bri-tunes-v3';
const CACHE = 'bri-tunes-v5';
const STATIC_PRECACHE = [
'/',
'/static/css/app.css',
@ -27,10 +27,26 @@ self.addEventListener('activate', (e) => {
self.clients.claim();
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
e.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
const focused = clients.find((c) => c.focused);
if (focused) return focused.focus();
const existing = clients[0];
if (existing) return existing.focus();
return self.clients.openWindow('/');
})
);
});
self.addEventListener('fetch', (e) => {
const { request } = e;
const url = new URL(request.url);
// Only handle GET requests — POST/PUT/DELETE must never be cached.
if (request.method !== 'GET') return;
// Never intercept audio streams or cross-origin requests.
if (url.pathname.startsWith('/stream/') || url.origin !== self.location.origin) return;

View File

@ -130,7 +130,8 @@ module.exports = function adminPanelRoutes(csrfProtection) {
comfyui_node_duration: (req.body.node_duration || '').trim() || null,
comfyui_node_creativity: (req.body.node_creativity || '').trim() || null,
comfyui_node_seed: (req.body.node_seed || '').trim() || null,
comfyui_node_image_prompt: (req.body.node_image_prompt || '').trim() || null,
comfyui_node_image_prompt: (req.body.node_image_prompt || '').trim() || null,
comfyui_node_image_seed: (req.body.node_image_seed || '').trim() || null,
image_generation_enabled: req.body.image_generation_enabled === '1' ? 1 : 0,
});
req.flash('success', 'Generation settings saved.');

View File

@ -379,7 +379,7 @@ module.exports = function adminRoutes(csrfProtection) {
router.get('/playlists/new', (req, res) => {
const allSongs = songs.listForManagement(req.session.user.id);
const preselectIds = (req.query.songs || '').split(',').map(Number).filter(Boolean);
res.render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors: {}, values: { visibility: 'logged_in' }, preselectIds });
res.render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors: {}, values: { visibility: 'logged_in' }, preselectIds, imageGenerationEnabled: false });
});
router.post('/playlists', playlistFields, csrfProtection, async (req, res, next) => {
@ -391,7 +391,7 @@ module.exports = function adminRoutes(csrfProtection) {
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
const allSongs = songs.listForManagement(user.id);
return res.status(400).render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors, values: req.body, preselectIds: [] });
return res.status(400).render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors, values: req.body, preselectIds: [], imageGenerationEnabled: false });
}
const coverFile = req.files?.cover?.[0];
let coverPath = null;
@ -440,6 +440,7 @@ module.exports = function adminRoutes(csrfProtection) {
errors: {},
values: { ...playlist },
preselectIds: [],
imageGenerationEnabled: settings.isImageGenerationEnabled(),
});
});
@ -460,6 +461,7 @@ module.exports = function adminRoutes(csrfProtection) {
title: 'Edit playlist', playlist: existing,
tracks: playlists.getTracks(id), allSongs,
errors, values: req.body,
imageGenerationEnabled: settings.isImageGenerationEnabled(),
});
}
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';

View File

@ -9,6 +9,7 @@ const { requireUser } = require('../middleware/auth');
const settings = require('../services/settings');
const generation = require('../services/generation');
const songs = require('../services/songs');
const playlists = require('../services/playlists');
const imageService = require('../services/image');
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
@ -400,6 +401,7 @@ module.exports = function generateRoutes(csrfProtection) {
return res.status(503).json({ error: 'Image workflow not configured. Contact the administrator.' });
}
const prompt = (req.body.prompt || '').trim();
const seed = parseInt(req.body.seed, 10);
const coverId = generation.createCover(req.session.user.id, songId);
try {
const promptId = await generation.submitToComfyUIImage(
@ -407,6 +409,8 @@ module.exports = function generateRoutes(csrfProtection) {
s.comfyui_image_workflow_json,
prompt,
s.comfyui_node_image_prompt,
s.comfyui_node_image_seed,
isNaN(seed) ? -1 : seed,
);
generation.coverUpdateJobStmt.run(promptId, 'processing', coverId);
} catch (err) {
@ -477,5 +481,112 @@ module.exports = function generateRoutes(csrfProtection) {
res.redirect('/mymusic/songs');
});
// ── Playlist cover generation routes ──────────────────────────────────────
// GET /playlist-cover/:playlistId — show cover generation form
router.get('/playlist-cover/:playlistId', requireImageGeneration, (req, res, next) => {
const playlistId = Number(req.params.playlistId);
const playlist = playlists.findById(playlistId);
if (!playlist || (playlist.createdBy !== req.session.user.id && req.session.user.role !== 'admin')) return next();
const s = settings.get();
const configured = !!(s.comfyui_image_workflow_json && s.comfyui_base_url);
res.render('generate/playlist_cover', {
title: 'Generate Cover Art',
playlist,
configured,
});
});
// POST /playlist-cover/:playlistId/submit — kick off a cover generation job
router.post('/playlist-cover/:playlistId/submit', requireImageGeneration, csrfProtection, async (req, res) => {
const playlistId = Number(req.params.playlistId);
const playlist = playlists.findById(playlistId);
if (!playlist || (playlist.createdBy !== req.session.user.id && req.session.user.role !== 'admin')) {
return res.status(404).json({ error: 'Playlist not found.' });
}
const s = settings.get();
if (!s.comfyui_image_workflow_json || !s.comfyui_base_url) {
return res.status(503).json({ error: 'Image workflow not configured. Contact the administrator.' });
}
const prompt = (req.body.prompt || '').trim();
const seed = parseInt(req.body.seed, 10);
const coverId = generation.createPlaylistCover(req.session.user.id, playlistId);
try {
const promptId = await generation.submitToComfyUIImage(
s.comfyui_base_url,
s.comfyui_image_workflow_json,
prompt,
s.comfyui_node_image_prompt,
s.comfyui_node_image_seed,
isNaN(seed) ? -1 : seed,
);
generation.playlistCoverUpdateJobStmt.run(promptId, 'processing', coverId);
} catch (err) {
generation.playlistCoverUpdateFailStmt.run('failed', err.message, coverId);
}
res.json({ ok: true, coverId });
});
// GET /playlist-cover/poll/:coverId — poll playlist cover job status
router.get('/playlist-cover/poll/:coverId', requireImageGeneration, async (req, res) => {
const coverId = Number(req.params.coverId);
const row = generation.findPlaylistCoverById(coverId);
if (!row || row.user_id !== req.session.user.id) {
return res.status(404).json({ error: 'Not found' });
}
if (row.status === 'done') {
return res.json({ status: 'done', previewPath: row.image_path });
}
if (row.status === 'failed') {
return res.json({ status: 'failed', error: row.error_message });
}
if (!row.job_id) return res.json({ status: row.status });
const s = settings.get();
try {
const { done, relPath } = await generation.pollComfyUIImage(s.comfyui_base_url, row.job_id, coverId);
if (done) {
generation.playlistCoverUpdateDoneStmt.run('done', relPath, coverId);
return res.json({ status: 'done', previewPath: relPath });
}
} catch (err) {
// Not ready yet — keep polling
}
res.json({ status: 'processing' });
});
// POST /playlist-cover/:playlistId/apply — process + save the generated cover to the playlist
router.post('/playlist-cover/:playlistId/apply', requireImageGeneration, csrfProtection, async (req, res, next) => {
try {
const playlistId = Number(req.params.playlistId);
const coverId = Number(req.body.coverId);
const playlist = playlists.findById(playlistId);
const coverRow = generation.findPlaylistCoverById(coverId);
if (!playlist || (playlist.createdBy !== req.session.user.id && req.session.user.role !== 'admin')) return next();
if (!coverRow || coverRow.user_id !== req.session.user.id || coverRow.status !== 'done' || !coverRow.image_path) {
req.flash('error', 'Cover image not ready.');
return res.redirect(`/generate/playlist-cover/${playlistId}`);
}
const srcAbs = path.join(generation.MEDIA_DIR, coverRow.image_path);
const finalName = `${crypto.randomUUID()}.jpg`;
const finalAbs = path.join(generation.MEDIA_DIR, 'covers', finalName);
await imageService.processCoverImage(srcAbs, finalAbs);
playlists.setCover(playlistId, `covers/${finalName}`);
fs.unlink(srcAbs, () => {});
generation.removePlaylistCover(coverId);
req.flash('success', 'Cover art applied!');
res.redirect(`/mymusic/playlists/${playlistId}/edit`);
} catch (err) { next(err); }
});
// POST /playlist-cover/:playlistId/skip — skip playlist cover generation
router.post('/playlist-cover/:playlistId/skip', requireImageGeneration, csrfProtection, (req, res) => {
res.redirect('/mymusic/playlists');
});
return router;
};

View File

@ -211,7 +211,7 @@ function removeCover(id) {
}
// Submit a single-prompt image workflow to ComfyUI. Returns prompt_id.
async function submitToComfyUIImage(baseUrl, workflowJson, prompt, nodeId) {
async function submitToComfyUIImage(baseUrl, workflowJson, prompt, nodeId, seedNodeId, seed) {
const workflow = JSON.parse(workflowJson);
if (nodeId && workflow[nodeId] && workflow[nodeId].inputs !== undefined) {
const inputs = workflow[nodeId].inputs;
@ -223,6 +223,15 @@ async function submitToComfyUIImage(baseUrl, workflowJson, prompt, nodeId) {
inputs.text = prompt;
}
}
if (seedNodeId && workflow[seedNodeId] && workflow[seedNodeId].inputs !== undefined) {
const resolvedSeed = (seed == null || seed === -1)
? Math.floor(Math.random() * 2 ** 32)
: seed;
console.log('[image] seed node=%s before=%s after=%s', seedNodeId, workflow[seedNodeId].inputs.seed, resolvedSeed);
workflow[seedNodeId].inputs.seed = resolvedSeed;
} else {
console.log('[image] seed injection skipped — seedNodeId=%s found=%s', seedNodeId, !!(seedNodeId && workflow[seedNodeId]));
}
const res = await fetch(`${baseUrl}/prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -262,6 +271,27 @@ async function pollComfyUIImage(baseUrl, promptId, coverId) {
return { done: true, relPath: `generated/${saveName}`, filename: saveName };
}
// ── Playlist cover generation ──────────────────────────────────────────────
const playlistCoverInsertStmt = db.prepare(`INSERT INTO generated_playlist_covers (user_id, playlist_id, status) VALUES (?, ?, 'pending')`);
const playlistCoverFindByIdStmt = db.prepare('SELECT * FROM generated_playlist_covers WHERE id = ?');
const playlistCoverUpdateJobStmt = db.prepare('UPDATE generated_playlist_covers SET job_id = ?, status = ? WHERE id = ?');
const playlistCoverUpdateDoneStmt = db.prepare('UPDATE generated_playlist_covers SET status = ?, image_path = ? WHERE id = ?');
const playlistCoverUpdateFailStmt = db.prepare('UPDATE generated_playlist_covers SET status = ?, error_message = ? WHERE id = ?');
const playlistCoverDeleteStmt = db.prepare('DELETE FROM generated_playlist_covers WHERE id = ?');
function createPlaylistCover(userId, playlistId) {
return playlistCoverInsertStmt.run(userId, playlistId).lastInsertRowid;
}
function findPlaylistCoverById(id) {
return playlistCoverFindByIdStmt.get(id);
}
function removePlaylistCover(id) {
playlistCoverDeleteStmt.run(id);
}
module.exports = {
MAX_GENERATED, HOURLY_LIMIT, GEN_DIR, MEDIA_DIR,
canGenerate, countByUser, listByUser, findById, create, remove,
@ -270,5 +300,7 @@ module.exports = {
submitToComfyUI, pollComfyUI,
createCover, findCoverById, removeCover,
coverUpdateJobStmt, coverUpdateDoneStmt, coverUpdateFailStmt,
createPlaylistCover, findPlaylistCoverById, removePlaylistCover,
playlistCoverUpdateJobStmt, playlistCoverUpdateDoneStmt, playlistCoverUpdateFailStmt,
submitToComfyUIImage, pollComfyUIImage,
};

View File

@ -16,6 +16,7 @@ const setStmt = db.prepare(`
comfyui_node_creativity = @comfyui_node_creativity,
comfyui_node_seed = @comfyui_node_seed,
comfyui_node_image_prompt = @comfyui_node_image_prompt,
comfyui_node_image_seed = @comfyui_node_image_seed,
image_generation_enabled = @image_generation_enabled
WHERE id = 1
`);

View File

@ -130,6 +130,9 @@
<label>Image Prompt node ID
<input type="text" name="node_image_prompt" value="<%= genSettings.comfyui_node_image_prompt || '' %>" placeholder="e.g. 20">
</label>
<label>Image Seed node ID
<input type="text" name="node_image_seed" value="<%= genSettings.comfyui_node_image_seed || '' %>" placeholder="e.g. 21">
</label>
</div>
</fieldset>

View File

@ -20,6 +20,10 @@
<textarea id="cover-prompt" rows="4"
placeholder="e.g. Synthwave city at night, neon lights reflecting on wet streets, dark sky with purple hues"></textarea>
</label>
<label style="max-width:200px">
Seed <span class="muted small">(-1 = random)</span>
<input type="number" id="cover-seed" value="-1" min="-1" max="4294967295">
</label>
<div style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap">
<button type="button" id="cover-submit-btn" class="btn-primary">Generate Cover Art</button>
<span id="cover-status" style="display:none;align-items:center;gap:0.5rem">
@ -39,17 +43,13 @@
<div style="display:flex;gap:1.5rem;align-items:flex-start;flex-wrap:wrap">
<img id="cover-preview" src="" alt="Generated cover art"
style="width:300px;height:300px;object-fit:cover;border-radius:var(--radius);border:1px solid var(--border)">
<div class="stacked" style="gap:0.75rem">
<form id="cover-apply-form" method="post" action="/generate/cover/<%= song.id %>/apply">
<div style="display:flex;flex-direction:column;gap:0.6rem;align-items:flex-start">
<form id="cover-apply-form" method="post" action="/generate/cover/<%= song.id %>/apply" style="margin:0">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="coverId" id="cover-id-input" value="">
<button type="submit" class="btn-primary">Use This Cover</button>
</form>
<button type="button" id="cover-retry-btn" class="btn-link">Try Again</button>
<form method="post" action="/generate/cover/<%= song.id %>/skip" style="margin:0">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-link">Skip for now</button>
</form>
<button type="button" id="cover-retry-btn" class="btn-link" style="margin-left:0">Try Again</button>
</div>
</div>
</div>
@ -71,6 +71,7 @@
var coverIdInput = document.getElementById('cover-id-input');
var retryBtn = document.getElementById('cover-retry-btn');
var promptEl = document.getElementById('cover-prompt');
var seedEl = document.getElementById('cover-seed');
if (!submitBtn) return;
@ -117,12 +118,13 @@
submitBtn.addEventListener('click', function () {
var prompt = promptEl ? promptEl.value.trim() : '';
var seed = seedEl ? seedEl.value : '-1';
resultEl.style.display = 'none';
errorEl.style.display = 'none';
setGenerating(true);
statusTxt.textContent = 'Submitting…';
var params = new URLSearchParams({ _csrf: csrfToken, prompt: prompt });
var params = new URLSearchParams({ _csrf: csrfToken, prompt: prompt, seed: seed });
fetch('/generate/cover/<%= song.id %>/submit', { method: 'POST', body: params })
.then(function (r) { return r.json(); })
.then(function (data) {

View File

@ -0,0 +1,151 @@
<div class="row-between" style="margin-bottom:1.5rem">
<div>
<h1 style="margin-bottom:0.25rem">Generate Cover Art</h1>
<p class="muted small" style="margin:0">
<strong><%= playlist.title %></strong>
</p>
</div>
<a href="/mymusic/playlists" class="btn-link">Skip for now</a>
</div>
<% if (!configured) { %>
<div class="form-card" style="padding:1.25rem 1.5rem">
<p class="muted">Image generation is not fully configured yet. Ask an administrator to upload an image workflow and set a base URL.</p>
</div>
<% } else { %>
<div class="form-card stacked" id="cover-form-wrap">
<label>
Describe the cover art
<textarea id="cover-prompt" rows="4"
placeholder="e.g. Synthwave city at night, neon lights reflecting on wet streets, dark sky with purple hues"></textarea>
</label>
<label style="max-width:200px">
Seed <span class="muted small">(-1 = random)</span>
<input type="number" id="cover-seed" value="-1" min="-1" max="4294967295">
</label>
<div style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap">
<button type="button" id="cover-submit-btn" class="btn-primary">Generate Cover Art</button>
<span id="cover-status" style="display:none;align-items:center;gap:0.5rem">
<span class="gen-spinner"></span>
<span id="cover-status-text" class="muted small">Generating…</span>
</span>
<form method="post" action="/generate/playlist-cover/<%= playlist.id %>/skip" style="margin:0">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-link">Skip for now</button>
</form>
</div>
</div>
<div id="cover-result" style="display:none;margin-top:1.5rem">
<div class="form-card" style="padding:1.25rem 1.5rem">
<h3 style="margin:0 0 1rem">Generated Cover</h3>
<div style="display:flex;gap:1.5rem;align-items:flex-start;flex-wrap:wrap">
<img id="cover-preview" src="" alt="Generated cover art"
style="width:300px;height:300px;object-fit:cover;border-radius:var(--radius);border:1px solid var(--border)">
<div style="display:flex;flex-direction:column;gap:0.6rem;align-items:flex-start">
<form id="cover-apply-form" method="post" action="/generate/playlist-cover/<%= playlist.id %>/apply" style="margin:0">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="coverId" id="cover-id-input" value="">
<button type="submit" class="btn-primary">Use This Cover</button>
</form>
<button type="button" id="cover-retry-btn" class="btn-link" style="margin-left:0">Try Again</button>
</div>
</div>
</div>
</div>
<div id="cover-error" class="flash flash-error" style="display:none;margin-top:1rem"></div>
<% } %>
<script>
(function () {
var csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
var submitBtn = document.getElementById('cover-submit-btn');
var statusEl = document.getElementById('cover-status');
var statusTxt = document.getElementById('cover-status-text');
var resultEl = document.getElementById('cover-result');
var previewImg = document.getElementById('cover-preview');
var errorEl = document.getElementById('cover-error');
var coverIdInput = document.getElementById('cover-id-input');
var retryBtn = document.getElementById('cover-retry-btn');
var promptEl = document.getElementById('cover-prompt');
var seedEl = document.getElementById('cover-seed');
if (!submitBtn) return;
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = '';
setTimeout(function () { errorEl.style.display = 'none'; }, 8000);
}
function setGenerating(on) {
submitBtn.disabled = on;
statusEl.style.display = on ? 'flex' : 'none';
if (!on) statusTxt.textContent = 'Generating…';
}
function poll(coverId) {
setTimeout(function () {
fetch('/generate/playlist-cover/poll/' + coverId)
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.status === 'done') {
setGenerating(false);
coverIdInput.value = coverId;
var imgUrl = '/media/generated/' + encodeURIComponent(data.previewPath.split('/').pop());
previewImg.onload = function () {
resultEl.style.display = '';
resultEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
};
previewImg.onerror = function () {
resultEl.style.display = '';
resultEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
};
previewImg.src = imgUrl;
} else if (data.status === 'failed') {
setGenerating(false);
showError('Generation failed: ' + (data.error || 'unknown error'));
} else {
poll(coverId);
}
})
.catch(function () { poll(coverId); });
}, 3000);
}
submitBtn.addEventListener('click', function () {
var prompt = promptEl ? promptEl.value.trim() : '';
var seed = seedEl ? seedEl.value : '-1';
resultEl.style.display = 'none';
errorEl.style.display = 'none';
setGenerating(true);
statusTxt.textContent = 'Submitting…';
var params = new URLSearchParams({ _csrf: csrfToken, prompt: prompt, seed: seed });
fetch('/generate/playlist-cover/<%= playlist.id %>/submit', { method: 'POST', body: params })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) {
setGenerating(false);
showError(data.error || 'Submission failed.');
return;
}
statusTxt.textContent = 'Generating… (this may take a minute)';
poll(data.coverId);
})
.catch(function () {
setGenerating(false);
showError('Network error. Please try again.');
});
});
if (retryBtn) {
retryBtn.addEventListener('click', function () {
resultEl.style.display = 'none';
});
}
})();
</script>

View File

@ -55,6 +55,19 @@
<script type="module" src="/static/js/overlay-objects.js"></script>
<script type="module" src="/static/js/player-viz.js"></script>
<script type="module" src="/static/js/boot-sequence.js"></script>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
navigator.serviceWorker.addEventListener('controllerchange', function () {
var banner = document.getElementById('sw-update-banner');
if (banner) banner.style.display = 'flex';
});
}
</script>
<div id="sw-update-banner" style="display:none" role="status">
<span>A new version is available.</span>
<button onclick="window.location.reload()">Refresh</button>
<button onclick="this.parentElement.style.display='none'" aria-label="Dismiss">✕</button>
</div>
</body>
</html>

View File

@ -13,10 +13,19 @@
Description
<textarea name="description" rows="3"><%= values.description || '' %></textarea>
</label>
<label>
Cover art
<input type="file" name="cover" accept="image/*">
</label>
<div>
<label>
Cover art
<input type="file" name="cover" accept="image/*">
</label>
<% if (playlist && imageGenerationEnabled) { %>
<div style="margin-top:0.4rem">
<a href="/generate/playlist-cover/<%= playlist.id %>" class="btn-link" style="font-size:0.85rem">
✦ Generate Cover Art
</a>
</div>
<% } %>
</div>
<fieldset class="visibility-field">
<legend>Visibility</legend>
<label class="checkbox">