Fix player layout and refresh playback
This commit is contained in:
parent
089fef215c
commit
c6490b9654
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
15
public/ui.js
15
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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue