diff --git a/public/channelSync.js b/public/channelSync.js
index 51d473d..8024cd8 100644
--- a/public/channelSync.js
+++ b/public/channelSync.js
@@ -251,6 +251,7 @@
}
M.currentChannelId = id;
M.rememberChannel(id);
+ setPlaybackBlocked(false);
const proto = location.protocol === "https:" ? "wss:" : "ws:";
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
@@ -278,6 +279,7 @@
M.wantSync = false;
M.synced = false;
M.audio.pause();
+ setPlaybackBlocked(false);
if (M.ws) {
const oldWs = M.ws;
M.ws = null;
@@ -351,6 +353,7 @@
M.ws.onclose = () => {
M.synced = false;
M.ws = null;
+ setPlaybackBlocked(false);
M.$("#sync-indicator").classList.add("disconnected");
M.updateUI();
// Auto-reconnect if user wants to be synced
@@ -372,6 +375,85 @@
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
M.handleUpdate = async function(data) {
@@ -402,6 +484,7 @@
if (!data.track) {
M.setTrackTitle("No tracks");
+ setPlaybackBlocked(false);
return;
}
M.serverTimestamp = data.currentTimestamp;
@@ -440,15 +523,11 @@
if (!M.serverPaused) {
// Server is playing - ensure we're playing and synced
if (isNewTrack || !M.audio.src) {
- // Try cache first
- const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
- M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
- M.audio.currentTime = data.currentTimestamp;
- M.audio.play().catch(() => {});
- } else if (M.audio.paused) {
- M.audio.currentTime = data.currentTimestamp;
- M.audio.play().catch(() => {});
+ await playSyncedAudio(data.currentTimestamp, true);
+ } else if (M.audio.paused || M.playbackBlocked) {
+ await playSyncedAudio(data.currentTimestamp, false);
} else {
+ setPlaybackBlocked(false);
// Check drift
const drift = Math.abs(M.audio.currentTime - data.currentTimestamp);
if (drift >= 2) {
@@ -458,6 +537,7 @@
}
} else {
// Server is paused - ensure we're paused too
+ setPlaybackBlocked(false);
if (!M.audio.paused) {
M.audio.pause();
}
diff --git a/public/controls.js b/public/controls.js
index fb8d7c0..571b69b 100644
--- a/public/controls.js
+++ b/public/controls.js
@@ -27,6 +27,10 @@
M.resumeVisualizer?.();
if (M.synced) {
+ if (!M.serverPaused && M.playbackBlocked) {
+ M.retryBlockedPlayback?.();
+ return;
+ }
if (blockSyncedControlIfNeeded()) return;
if (M.ws && M.ws.readyState === WebSocket.OPEN) {
M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" }));
diff --git a/public/index.html b/public/index.html
index 858b39e..57b2b7e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,7 +4,7 @@
Blastoise! A very special music server
-
+
diff --git a/public/styles.css b/public/styles.css
index bfd5b2a..a32d209 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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-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 { order: 2; }
-#queue-panel { order: 3; }
+#queue-panel { order: 2; }
+#library-panel { order: 3; }
.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: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:hover { opacity: 0.8; }
#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; }
#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; }
@@ -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: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.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-track { background-color: #111; border-radius: 3px; }
@@ -572,9 +575,22 @@ button:hover { background: #333; }
/* Player bar - stacked layout */
.visualizer-shell {
height: 74px;
+ min-height: 74px;
+ max-height: 74px;
+ flex: 0 0 74px;
margin: 0 0 0.3rem 0;
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 {
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"] #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"] #player-bar { border-top: 4px solid var(--accent); }
diff --git a/public/trackContainer.js b/public/trackContainer.js
index fadbf29..3c219fc 100644
--- a/public/trackContainer.js
+++ b/public/trackContainer.js
@@ -29,6 +29,39 @@
// Active context menu
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
@@ -133,7 +166,7 @@
function wirePlaylistContainerDrop(container) {
container.ondragover = (e) => {
- if (dragSource === 'queue' || dragSource === 'library' || dragSource === 'playlist') {
+ if (hasTrackDrag(e, ['queue', 'library', 'playlist'])) {
e.preventDefault();
e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy";
container.classList.add("drop-target");
@@ -153,6 +186,7 @@
el.classList.remove("drop-above", "drop-below");
});
+ restoreDragState(e);
if (draggedTrackIds.length > 0) {
e.preventDefault();
@@ -391,6 +425,11 @@
div.classList.add("dragging");
// Use "copyMove" to allow both copy and move operations
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(",")}`);
}
@@ -414,6 +453,7 @@
}
function handleDragOver(e, div, index) {
+ if (!hasTrackDrag(e, ['queue', 'library', 'playlist'])) return;
e.preventDefault();
// Set drop effect based on source
@@ -444,6 +484,7 @@
}
function handleDrop(e, div, index) {
+ restoreDragState(e);
console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`);
e.preventDefault();
e.stopPropagation();
@@ -489,7 +530,7 @@
function wireQueueContainerDrop(container) {
container.ondragover = (e) => {
- if (dragSource === 'library' || dragSource === 'playlist') {
+ if (hasTrackDrag(e, ['library', 'playlist'])) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
if (M.queue.length === 0) {
@@ -506,6 +547,7 @@
container.ondrop = (e) => {
container.classList.remove("drop-target");
+ restoreDragState(e);
if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
e.preventDefault();
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
diff --git a/public/ui.js b/public/ui.js
index 80bbd70..5a4b15e 100644
--- a/public/ui.js
+++ b/public/ui.js
@@ -14,8 +14,9 @@
// Update general UI state
M.updateUI = function() {
const isConnecting = M.wantSync && !M.synced;
+ const playbackBlocked = M.synced && M.playbackBlocked && !M.serverPaused;
// 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("connected", M.synced);
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("muted", 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
const hasControl = M.canControl();
@@ -33,10 +37,11 @@
["#status-icon", "#btn-prev", "#btn-next", "#btn-mode", "#progress-container"].forEach(sel => {
const el = M.$(sel);
if (!el) return;
- el.classList.toggle("control-disabled", controlsDisabled);
- if ("ariaDisabled" in el) el.ariaDisabled = controlsDisabled ? "true" : "false";
+ const disabled = controlsDisabled && !(sel === "#status-icon" && playbackBlocked);
+ 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
diff --git a/public/upload.js b/public/upload.js
index 1226647..d8964fe 100644
--- a/public/upload.js
+++ b/public/upload.js
@@ -165,18 +165,23 @@
// Drag and drop on library panel
let dragCounter = 0;
+
+ function isFileDrag(e) {
+ return [...(e.dataTransfer?.types || [])].includes("Files");
+ }
libraryPanel.ondragenter = (e) => {
if (!M.currentUser) return;
- if (!e.dataTransfer.types.includes("Files")) return;
+ if (!isFileDrag(e)) return;
e.preventDefault();
dragCounter++;
dropzone.classList.remove("hidden");
};
libraryPanel.ondragleave = (e) => {
+ if (!isFileDrag(e)) return;
e.preventDefault();
- dragCounter--;
+ dragCounter = Math.max(0, dragCounter - 1);
if (dragCounter === 0) {
dropzone.classList.add("hidden");
}
@@ -184,12 +189,13 @@
libraryPanel.ondragover = (e) => {
if (!M.currentUser) return;
- if (!e.dataTransfer.types.includes("Files")) return;
+ if (!isFileDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
libraryPanel.ondrop = (e) => {
+ if (!isFileDrag(e)) return;
e.preventDefault();
dragCounter = 0;
dropzone.classList.add("hidden");
diff --git a/public/visualizer.js b/public/visualizer.js
index ad083ff..3e7e8d0 100644
--- a/public/visualizer.js
+++ b/public/visualizer.js
@@ -675,6 +675,7 @@
mode = normalizeMode(nextMode);
if (selector) selector.value = mode;
if (shell) shell.classList.toggle("hidden", mode === "off");
+ document.getElementById("player-content")?.classList.toggle("visualizer-active", mode !== "off");
if (remember) rememberMode(mode);
if (mode === "off") {