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:
parent
8170cfb7a2
commit
a4954dfaa8
|
|
@ -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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE site_settings ADD COLUMN comfyui_node_image_seed TEXT;
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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() } }));
|
||||
|
|
|
|||
18
public/sw.js
18
public/sw.js
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue