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") {