Fix player layout and refresh playback

This commit is contained in:
peterino2 2026-07-05 10:32:03 -07:00
parent ee63f61e01
commit 053aaa8ef6
8 changed files with 177 additions and 23 deletions

View File

@ -251,6 +251,7 @@
} }
M.currentChannelId = id; M.currentChannelId = id;
M.rememberChannel(id); M.rememberChannel(id);
setPlaybackBlocked(false);
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws"); M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
@ -278,6 +279,7 @@
M.wantSync = false; M.wantSync = false;
M.synced = false; M.synced = false;
M.audio.pause(); M.audio.pause();
setPlaybackBlocked(false);
if (M.ws) { if (M.ws) {
const oldWs = M.ws; const oldWs = M.ws;
M.ws = null; M.ws = null;
@ -351,6 +353,7 @@
M.ws.onclose = () => { M.ws.onclose = () => {
M.synced = false; M.synced = false;
M.ws = null; M.ws = null;
setPlaybackBlocked(false);
M.$("#sync-indicator").classList.add("disconnected"); M.$("#sync-indicator").classList.add("disconnected");
M.updateUI(); M.updateUI();
// Auto-reconnect if user wants to be synced // Auto-reconnect if user wants to be synced
@ -372,6 +375,85 @@
M.updateUI(); M.updateUI();
}; };
}; };
let playbackUnlockListenersInstalled = false;
let playbackBlockedToastShown = false;
function removePlaybackUnlockListeners() {
if (!playbackUnlockListenersInstalled) return;
document.removeEventListener("pointerdown", retryPlaybackFromUserActivation, true);
document.removeEventListener("keydown", retryPlaybackFromUserActivation, true);
playbackUnlockListenersInstalled = false;
}
function installPlaybackUnlockListeners() {
if (playbackUnlockListenersInstalled) return;
document.addEventListener("pointerdown", retryPlaybackFromUserActivation, true);
document.addEventListener("keydown", retryPlaybackFromUserActivation, true);
playbackUnlockListenersInstalled = true;
}
function setPlaybackBlocked(blocked) {
const changed = M.playbackBlocked !== blocked;
M.playbackBlocked = blocked;
if (blocked) {
installPlaybackUnlockListeners();
if (!playbackBlockedToastShown) {
playbackBlockedToastShown = true;
M.showToast("Click play or press any key to resume this channel", "warning", 7000);
}
} else {
playbackBlockedToastShown = false;
removePlaybackUnlockListeners();
}
if (changed) M.updateUI?.();
}
async function ensureSyncedAudioSource(timestamp, forceReload = false) {
if (!M.currentTrackId) return;
if (forceReload || !M.audio.src) {
const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
}
const nextTime = Number.isFinite(timestamp) ? Math.max(0, timestamp) : 0;
try {
M.audio.currentTime = nextTime;
} catch (error) {
console.warn("[Playback] Unable to set synced time yet", error);
}
}
async function playSyncedAudio(timestamp, forceReload = false) {
if (!M.currentTrackId || M.serverPaused) return false;
await ensureSyncedAudioSource(timestamp, forceReload);
M.resumeVisualizer?.();
try {
await M.audio.play();
setPlaybackBlocked(false);
return true;
} catch (error) {
console.warn("[Playback] Browser blocked or delayed autoplay", error);
setPlaybackBlocked(true);
return false;
}
}
function retryPlaybackFromUserActivation() {
if (!M.playbackBlocked) return;
M.retryBlockedPlayback?.();
}
M.retryBlockedPlayback = async function() {
if (!M.currentTrackId || !M.synced || M.serverPaused) return false;
return playSyncedAudio(M.getServerTime(), !M.audio.src);
};
M.audio.addEventListener("play", () => setPlaybackBlocked(false));
// Handle channel state update from server // Handle channel state update from server
M.handleUpdate = async function(data) { M.handleUpdate = async function(data) {
@ -402,6 +484,7 @@
if (!data.track) { if (!data.track) {
M.setTrackTitle("No tracks"); M.setTrackTitle("No tracks");
setPlaybackBlocked(false);
return; return;
} }
M.serverTimestamp = data.currentTimestamp; M.serverTimestamp = data.currentTimestamp;
@ -440,15 +523,11 @@
if (!M.serverPaused) { if (!M.serverPaused) {
// Server is playing - ensure we're playing and synced // Server is playing - ensure we're playing and synced
if (isNewTrack || !M.audio.src) { if (isNewTrack || !M.audio.src) {
// Try cache first await playSyncedAudio(data.currentTimestamp, true);
const cachedUrl = await M.loadTrackBlob(M.currentTrackId); } else if (M.audio.paused || M.playbackBlocked) {
M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId); await playSyncedAudio(data.currentTimestamp, false);
M.audio.currentTime = data.currentTimestamp;
M.audio.play().catch(() => {});
} else if (M.audio.paused) {
M.audio.currentTime = data.currentTimestamp;
M.audio.play().catch(() => {});
} else { } else {
setPlaybackBlocked(false);
// Check drift // Check drift
const drift = Math.abs(M.audio.currentTime - data.currentTimestamp); const drift = Math.abs(M.audio.currentTime - data.currentTimestamp);
if (drift >= 2) { if (drift >= 2) {
@ -458,6 +537,7 @@
} }
} else { } else {
// Server is paused - ensure we're paused too // Server is paused - ensure we're paused too
setPlaybackBlocked(false);
if (!M.audio.paused) { if (!M.audio.paused) {
M.audio.pause(); M.audio.pause();
} }

4
public/controls.js vendored
View File

@ -27,6 +27,10 @@
M.resumeVisualizer?.(); M.resumeVisualizer?.();
if (M.synced) { if (M.synced) {
if (!M.serverPaused && M.playbackBlocked) {
M.retryBlockedPlayback?.();
return;
}
if (blockSyncedControlIfNeeded()) return; if (blockSyncedControlIfNeeded()) return;
if (M.ws && M.ws.readyState === WebSocket.OPEN) { if (M.ws && M.ws.readyState === WebSocket.OPEN) {
M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" })); M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" }));

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blastoise! A very special music server</title> <title>Blastoise! A very special music server</title>
<link rel="stylesheet" href="/styles.css?v=22"> <link rel="stylesheet" href="/styles.css?v=23">
</head> </head>
<body> <body>
<div id="app"> <div id="app">

View File

@ -48,8 +48,8 @@ h3 { font-size: 0.8rem; color: #999; margin-bottom: 0.3rem; text-transform: uppe
#channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; } #channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; }
#channels-list .listener-mult { color: #999; font-size: 0.55rem; } #channels-list .listener-mult { color: #999; font-size: 0.55rem; }
#library-panel, #queue-panel { flex: 1 1 0; min-width: 0; max-width: 100%; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; } #library-panel, #queue-panel { flex: 1 1 0; min-width: 0; max-width: 100%; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; }
#library-panel { order: 2; } #queue-panel { order: 2; }
#queue-panel { order: 3; } #library-panel { order: 3; }
.panel-tabs { display: flex; gap: 0; margin-bottom: 0; flex-shrink: 0; } .panel-tabs { display: flex; gap: 0; margin-bottom: 0; flex-shrink: 0; }
.panel-tab { background: #252525; border: none; color: #999; font-family: inherit; font-size: 0.8rem; font-weight: bold; padding: 0.3rem 0.6rem; cursor: pointer; border-radius: 4px 4px 0 0; text-transform: uppercase; letter-spacing: 0.05em; margin-right: 2px; } .panel-tab { background: #252525; border: none; color: #999; font-family: inherit; font-size: 0.8rem; font-weight: bold; padding: 0.3rem 0.6rem; cursor: pointer; border-radius: 4px 4px 0 0; text-transform: uppercase; letter-spacing: 0.05em; margin-right: 2px; }
.panel-tab:hover { color: #aaa; background: #2a2a2a; } .panel-tab:hover { color: #aaa; background: #2a2a2a; }
@ -244,6 +244,7 @@ h3 { font-size: 0.8rem; color: #999; margin-bottom: 0.3rem; text-transform: uppe
#btn-mode.mode-shuffle { color: #c4f; text-shadow: 0 0 6px #c4f; } #btn-mode.mode-shuffle { color: #c4f; text-shadow: 0 0 6px #c4f; }
#btn-mode:hover { opacity: 0.8; } #btn-mode:hover { opacity: 0.8; }
#status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; } #status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; }
#status-icon.playback-blocked { color: #eb0; text-shadow: 0 0 8px rgba(238, 187, 0, 0.65); }
.control-disabled { opacity: 0.45 !important; cursor: not-allowed !important; } .control-disabled { opacity: 0.45 !important; cursor: not-allowed !important; }
#progress-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; min-width: 0; } #progress-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; min-width: 0; }
#progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; } #progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; }
@ -293,8 +294,10 @@ button:hover { background: #333; }
#guest-section .guest-btn { width: 100%; background: #333; color: #eee; border: 1px solid #444; padding: 0.5rem; border-radius: 4px; font-size: 0.9rem; cursor: pointer; } #guest-section .guest-btn { width: 100%; background: #333; color: #eee; border: 1px solid #444; padding: 0.5rem; border-radius: 4px; font-size: 0.9rem; cursor: pointer; }
#guest-section .guest-btn:hover { background: #444; } #guest-section .guest-btn:hover { background: #444; }
#player-content { display: none; flex-direction: column; flex: 1; } #player-content { display: none; flex-direction: column; flex: 1; min-height: 0; }
#player-content.visible { display: flex; } #player-content.visible { display: flex; }
#player-content.visualizer-active #main-content { flex: 0 1 60vh; max-height: 60vh; }
#player-content.visualizer-active .visualizer-shell { flex: 1 1 118px; height: auto; min-height: 118px; }
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background-color: #111; border-radius: 3px; } ::-webkit-scrollbar-track { background-color: #111; border-radius: 3px; }
@ -572,9 +575,22 @@ button:hover { background: #333; }
/* Player bar - stacked layout */ /* Player bar - stacked layout */
.visualizer-shell { .visualizer-shell {
height: 74px; height: 74px;
min-height: 74px;
max-height: 74px;
flex: 0 0 74px;
margin: 0 0 0.3rem 0; margin: 0 0 0.3rem 0;
max-width: 100%; max-width: 100%;
} }
#player-content.visualizer-active #main-content {
flex: 1;
max-height: none;
}
#player-content.visualizer-active .visualizer-shell {
flex: 0 0 74px;
height: 74px;
min-height: 74px;
max-height: 74px;
}
#player-bar { #player-bar {
flex-direction: column; flex-direction: column;
@ -1006,7 +1022,7 @@ body[data-ui-theme="cinema"] {
} }
body[data-ui-theme="cinema"] #main-content { display: grid; grid-template-columns: 150px minmax(280px, 0.8fr) minmax(420px, 1.45fr); } body[data-ui-theme="cinema"] #main-content { display: grid; grid-template-columns: 150px minmax(280px, 0.8fr) minmax(420px, 1.45fr); }
body[data-ui-theme="cinema"] #queue-panel { order: 3; max-height: 70vh; } body[data-ui-theme="cinema"] #queue-panel { order: 2; max-height: 70vh; }
body[data-ui-theme="cinema"] #queue .track.active { font-size: 1rem; padding: 0.65rem 0.75rem; } body[data-ui-theme="cinema"] #queue .track.active { font-size: 1rem; padding: 0.65rem 0.75rem; }
body[data-ui-theme="cinema"] #player-bar { border-top: 4px solid var(--accent); } body[data-ui-theme="cinema"] #player-bar { border-top: 4px solid var(--accent); }

View File

@ -29,6 +29,39 @@
// Active context menu // Active context menu
let activeContextMenu = null; let activeContextMenu = null;
const TRACK_DRAG_TYPE = "application/x-blastoise-tracks";
function getDataTransferTypes(e) {
return [...(e.dataTransfer?.types || [])];
}
function hasTrackDrag(e, allowedSources = null) {
if (dragSource) return !allowedSources || allowedSources.includes(dragSource);
return getDataTransferTypes(e).includes(TRACK_DRAG_TYPE);
}
function restoreDragState(e) {
if (dragSource && draggedTrackIds.length > 0) return true;
const raw = e.dataTransfer?.getData(TRACK_DRAG_TYPE);
if (!raw) return false;
try {
const payload = JSON.parse(raw);
if (!["queue", "library", "playlist"].includes(payload.source)) return false;
const trackIds = Array.isArray(payload.trackIds) ? payload.trackIds.filter(Boolean) : [];
if (trackIds.length === 0) return false;
dragSource = payload.source;
draggedTrackIds = trackIds;
draggedIndices = Array.isArray(payload.indices)
? payload.indices.filter(i => Number.isInteger(i))
: [];
return true;
} catch {
return false;
}
}
/** /**
* Create a track container manager * Create a track container manager
@ -133,7 +166,7 @@
function wirePlaylistContainerDrop(container) { function wirePlaylistContainerDrop(container) {
container.ondragover = (e) => { container.ondragover = (e) => {
if (dragSource === 'queue' || dragSource === 'library' || dragSource === 'playlist') { if (hasTrackDrag(e, ['queue', 'library', 'playlist'])) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy"; e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy";
container.classList.add("drop-target"); container.classList.add("drop-target");
@ -153,6 +186,7 @@
el.classList.remove("drop-above", "drop-below"); el.classList.remove("drop-above", "drop-below");
}); });
restoreDragState(e);
if (draggedTrackIds.length > 0) { if (draggedTrackIds.length > 0) {
e.preventDefault(); e.preventDefault();
@ -391,6 +425,11 @@
div.classList.add("dragging"); div.classList.add("dragging");
// Use "copyMove" to allow both copy and move operations // Use "copyMove" to allow both copy and move operations
e.dataTransfer.effectAllowed = "copyMove"; e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData(TRACK_DRAG_TYPE, JSON.stringify({
source: type,
trackIds: draggedTrackIds,
indices: draggedIndices
}));
e.dataTransfer.setData("text/plain", `${type}:${draggedTrackIds.join(",")}`); e.dataTransfer.setData("text/plain", `${type}:${draggedTrackIds.join(",")}`);
} }
@ -414,6 +453,7 @@
} }
function handleDragOver(e, div, index) { function handleDragOver(e, div, index) {
if (!hasTrackDrag(e, ['queue', 'library', 'playlist'])) return;
e.preventDefault(); e.preventDefault();
// Set drop effect based on source // Set drop effect based on source
@ -444,6 +484,7 @@
} }
function handleDrop(e, div, index) { function handleDrop(e, div, index) {
restoreDragState(e);
console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`); console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`);
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -489,7 +530,7 @@
function wireQueueContainerDrop(container) { function wireQueueContainerDrop(container) {
container.ondragover = (e) => { container.ondragover = (e) => {
if (dragSource === 'library' || dragSource === 'playlist') { if (hasTrackDrag(e, ['library', 'playlist'])) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = "copy"; e.dataTransfer.dropEffect = "copy";
if (M.queue.length === 0) { if (M.queue.length === 0) {
@ -506,6 +547,7 @@
container.ondrop = (e) => { container.ondrop = (e) => {
container.classList.remove("drop-target"); container.classList.remove("drop-target");
restoreDragState(e);
if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) { if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
e.preventDefault(); e.preventDefault();
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length; const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;

View File

@ -14,8 +14,9 @@
// Update general UI state // Update general UI state
M.updateUI = function() { M.updateUI = function() {
const isConnecting = M.wantSync && !M.synced; const isConnecting = M.wantSync && !M.synced;
const playbackBlocked = M.synced && M.playbackBlocked && !M.serverPaused;
// While connecting, treat as not playing (paused state) // While connecting, treat as not playing (paused state)
const isPlaying = M.synced ? !M.serverPaused : (!isConnecting && !M.audio.paused); const isPlaying = M.synced ? (!M.serverPaused && !M.audio.paused && !playbackBlocked) : (!isConnecting && !M.audio.paused);
M.$("#btn-sync").classList.toggle("synced", M.wantSync); M.$("#btn-sync").classList.toggle("synced", M.wantSync);
M.$("#btn-sync").classList.toggle("connected", M.synced); M.$("#btn-sync").classList.toggle("connected", M.synced);
M.$("#btn-sync").title = M.wantSync ? "Unsync" : "Sync"; M.$("#btn-sync").title = M.wantSync ? "Unsync" : "Sync";
@ -25,7 +26,10 @@
M.$("#progress-bar").classList.toggle("local", !M.synced); M.$("#progress-bar").classList.toggle("local", !M.synced);
M.$("#progress-bar").classList.toggle("muted", M.audio.volume === 0); M.$("#progress-bar").classList.toggle("muted", M.audio.volume === 0);
M.$("#btn-mute").textContent = M.audio.volume === 0 ? "🔇" : "🔊"; M.$("#btn-mute").textContent = M.audio.volume === 0 ? "🔇" : "🔊";
M.$("#status-icon").textContent = isPlaying ? "⏸" : "▶"; const statusIcon = M.$("#status-icon");
statusIcon.textContent = isPlaying ? "⏸" : "▶";
statusIcon.classList.toggle("playback-blocked", playbackBlocked);
statusIcon.title = playbackBlocked ? "Resume this channel" : (isPlaying ? "Pause" : "Play");
// Show/hide controls based on permissions // Show/hide controls based on permissions
const hasControl = M.canControl(); const hasControl = M.canControl();
@ -33,10 +37,11 @@
["#status-icon", "#btn-prev", "#btn-next", "#btn-mode", "#progress-container"].forEach(sel => { ["#status-icon", "#btn-prev", "#btn-next", "#btn-mode", "#progress-container"].forEach(sel => {
const el = M.$(sel); const el = M.$(sel);
if (!el) return; if (!el) return;
el.classList.toggle("control-disabled", controlsDisabled); const disabled = controlsDisabled && !(sel === "#status-icon" && playbackBlocked);
if ("ariaDisabled" in el) el.ariaDisabled = controlsDisabled ? "true" : "false"; el.classList.toggle("control-disabled", disabled);
if ("ariaDisabled" in el) el.ariaDisabled = disabled ? "true" : "false";
}); });
M.$("#status-icon").style.cursor = controlsDisabled ? "not-allowed" : "pointer"; statusIcon.style.cursor = controlsDisabled && !playbackBlocked ? "not-allowed" : "pointer";
}; };
// Update auth-related UI // Update auth-related UI

View File

@ -165,18 +165,23 @@
// Drag and drop on library panel // Drag and drop on library panel
let dragCounter = 0; let dragCounter = 0;
function isFileDrag(e) {
return [...(e.dataTransfer?.types || [])].includes("Files");
}
libraryPanel.ondragenter = (e) => { libraryPanel.ondragenter = (e) => {
if (!M.currentUser) return; if (!M.currentUser) return;
if (!e.dataTransfer.types.includes("Files")) return; if (!isFileDrag(e)) return;
e.preventDefault(); e.preventDefault();
dragCounter++; dragCounter++;
dropzone.classList.remove("hidden"); dropzone.classList.remove("hidden");
}; };
libraryPanel.ondragleave = (e) => { libraryPanel.ondragleave = (e) => {
if (!isFileDrag(e)) return;
e.preventDefault(); e.preventDefault();
dragCounter--; dragCounter = Math.max(0, dragCounter - 1);
if (dragCounter === 0) { if (dragCounter === 0) {
dropzone.classList.add("hidden"); dropzone.classList.add("hidden");
} }
@ -184,12 +189,13 @@
libraryPanel.ondragover = (e) => { libraryPanel.ondragover = (e) => {
if (!M.currentUser) return; if (!M.currentUser) return;
if (!e.dataTransfer.types.includes("Files")) return; if (!isFileDrag(e)) return;
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = "copy"; e.dataTransfer.dropEffect = "copy";
}; };
libraryPanel.ondrop = (e) => { libraryPanel.ondrop = (e) => {
if (!isFileDrag(e)) return;
e.preventDefault(); e.preventDefault();
dragCounter = 0; dragCounter = 0;
dropzone.classList.add("hidden"); dropzone.classList.add("hidden");

View File

@ -675,6 +675,7 @@
mode = normalizeMode(nextMode); mode = normalizeMode(nextMode);
if (selector) selector.value = mode; if (selector) selector.value = mode;
if (shell) shell.classList.toggle("hidden", mode === "off"); if (shell) shell.classList.toggle("hidden", mode === "off");
document.getElementById("player-content")?.classList.toggle("visualizer-active", mode !== "off");
if (remember) rememberMode(mode); if (remember) rememberMode(mode);
if (mode === "off") { if (mode === "off") {