"use strict"; const $ = id => document.getElementById(id); const searchForm = $("searchForm"); const searchInput = $("searchInput"); const statusEl = $("status"); const resultsBody = $("resultsBody"); const resultsSection = $("resultsSection"); const scrollSentinel = $("scrollSentinel"); const playerBar = $("playerBar"); const audioPlayer = $("audioPlayer"); const prevTrackButton = $("prevTrackButton"); const nextTrackButton = $("nextTrackButton"); const autoplayButton = $("autoplayButton"); const shuffleButton = $("shuffleButton"); const loopButton = $("loopButton"); const playerTitle = $("playerTitle"); const playerMeta = $("playerMeta"); const backButton = $("backButton"); const favoritesTab = $("favoritesTab"); const playlistsTab = $("playlistsTab"); const playPauseButton = $("playPauseButton"); const seekBar = $("seekBar"); const currentTimeLabel = $("currentTime"); const durationLabel = $("duration"); const volumeSlider = $("volumeSlider"); const seekThemeButton = $("seekThemeButton"); const seekThemeMenu = $("seekThemeMenu"); const searchHero = $("searchHero"); const pageRoot = $("pageRoot"); const queuePanel = $("queuePanel"); const queueList = $("queueList"); const queueModeSelect = $("queueModeSelect"); const queueRefreshButton = $("queueRefreshButton"); const PAGE_SIZE = 20; const MAX_AUTO_RESULTS = 100; const MAX_AUTO_PAGES = Math.ceil(MAX_AUTO_RESULTS / PAGE_SIZE); const COLSPAN = 9; const ANON_ID_KEY = "minimal_yt_anonymous_id"; const AUTOPLAY_KEY = "minimal_yt_autoplay_enabled"; const SHUFFLE_KEY = "minimal_yt_shuffle_enabled"; const LOOP_KEY = "minimal_yt_loop_mode"; const VOLUME_KEY = "minimal_yt_volume"; const SEEK_THEME_KEY_PREFIX = "minimal_yt_seek_theme"; const DEFAULT_PAGE_TITLE = document.title.trim() || "search"; let queueMode = localStorage.getItem("minimal_yt_queue_mode") === "recommendations" ? "recommendations" : "queue"; let playlistReorderDragId = null; let currentView = "search"; let currentQuery = ""; let currentPage = 0; let hasMore = false; let isLoading = false; let searchToken = 0; let loadedCount = 0; let currentPlaylistId = null; let currentPlaylistItems = []; let favoriteIds = new Set(); let playlistQueue = []; let playlistQueueIndex = -1; let shufflePlayedIndexes = new Set(); let autoplayEnabled = localStorage.getItem(AUTOPLAY_KEY) !== "false"; let shuffleEnabled = localStorage.getItem(SHUFFLE_KEY) === "true"; let loopMode = ["off", "all", "one"].includes(localStorage.getItem(LOOP_KEY)) ? localStorage.getItem(LOOP_KEY) : "off"; let activeTrackTitle = ""; let playbackRequestToken = 0; let searchAbortController = null; let preloadPromise = null; let currentTrackContext = null; let restoringPlayerState = false; let playerStateSaveTimer = null; let playerStateRestorePromise = null; let playTrackBusy = false; let playlistItemDragId = null; let playlistItemDragPlaylistId = null; function reorderArrayByIds(items, draggedId, targetId) { const list = [...items]; const fromIndex = list.findIndex(item => item.playlist_item_id === draggedId); const toIndex = list.findIndex(item => item.playlist_item_id === targetId); if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { return list; } const [moved] = list.splice(fromIndex, 1); list.splice(toIndex, 0, moved); return list; } const validSeekThemes = new Map( [...document.querySelectorAll(".seek-theme-option")].map(option => [ option.dataset.seekTheme, option.dataset.seekColor, ]), ); function getAnonymousId() { let id = localStorage.getItem(ANON_ID_KEY); if (!id) { id = globalThis.crypto?.randomUUID?.() || `anon_${Date.now()}_${Math.random().toString(36).slice(2)}`; localStorage.setItem(ANON_ID_KEY, id); } return id; } function apiFetch(url, options = {}) { return fetch(url, { ...options, credentials: "same-origin", headers: { ...(options.headers || {}), "X-Anonymous-Id": getAnonymousId(), }, }); } async function apiJson(url, options = {}) { const response = await apiFetch(url, options); if (!response.ok) { let detail = `request failed (${response.status})`; try { const data = await response.json(); if (typeof data.detail === "string") detail = data.detail; } catch {} const error = new Error(detail); error.status = response.status; throw error; } return response.json(); } function setStatus(text) { if (statusEl) statusEl.textContent = text || ""; } function setSentinel(text) { if (!scrollSentinel) return; scrollSentinel.textContent = text || ""; scrollSentinel.classList.toggle("loading", text === "loading"); } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function sanitizeStoredTrack(item) { if (!item || typeof item !== "object") return null; const id = String(item.id || "").trim(); if (!/^[A-Za-z0-9_-]{6,32}$/.test(id)) return null; const title = sanitizeTitle(item.title || "untitled", 120); const channel = item.channel != null ? sanitizeTitle(item.channel, 80) : undefined; const duration = Number(item.duration); const duration_string = item.duration_string != null ? sanitizeTitle(item.duration_string, 32) : undefined; const thumbnail = item.thumbnail != null ? String(item.thumbnail).trim() : undefined; const view_count = item.view_count != null && Number.isFinite(Number(item.view_count)) ? Math.max(0, Number(item.view_count)) : undefined; return { id, title, ...(channel ? { channel } : {}), ...(Number.isFinite(duration) && duration >= 0 && duration <= 86400 ? { duration } : {}), ...(duration_string ? { duration_string } : {}), ...(thumbnail ? { thumbnail } : {}), ...(view_count != null ? { view_count } : {}), is_live: Boolean(item.is_live), source: String(item.source || "youtube").slice(0, 32), ...(item.url ? { url: String(item.url) } : {}), }; } function sanitizeTitle(value, maxLength = 72) { let title = String(value || "") .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") .replace(/[\u202a-\u202e\u2066-\u2069]/g, "") .replace(/\s+/g, " ") .trim(); if (!title) return "untitled"; if (title.length > maxLength) { title = `${title.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`; } return title; } function formatViewCount(value) { const count = Number(value); if (!Number.isFinite(count)) return "-"; if (count >= 1e9) return `${(count / 1e9).toFixed(count >= 1e10 ? 0 : 1)}b`; if (count >= 1e6) return `${(count / 1e6).toFixed(count >= 1e7 ? 0 : 1)}m`; if (count >= 1e3) return `${(count / 1e3).toFixed(count >= 1e4 ? 0 : 1)}k`; return String(count); } function formatTime(seconds) { if (!Number.isFinite(seconds)) return "0:00"; return `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`; } function getItemKey(item) { return String(item?.id || item?.url || item?.title || ""); } function withLoadingGuard(flagGetter, flagSetter, fn) { return async (...args) => { if (flagGetter()) return; try { flagSetter(true); return await fn(...args); } finally { flagSetter(false); } }; } function extractVideoIdFromItem(item) { const direct = String(item?.id || "").trim(); if (/^[A-Za-z0-9_-]{6,32}$/.test(direct)) return direct; try { const url = new URL(String(item?.url || item?.webpage_url || "").trim()); const host = url.hostname.toLowerCase(); if (host === "youtu.be") { const id = url.pathname.replace(/^\/+/, "").split("/")[0]; return /^[A-Za-z0-9_-]{6,32}$/.test(id || "") ? id : null; } if ( host === "youtube.com" || host === "www.youtube.com" || host === "music.youtube.com" || host === "m.youtube.com" || host.endsWith(".youtube.com") ) { if (url.pathname === "/watch") { const id = url.searchParams.get("v"); return /^[A-Za-z0-9_-]{6,32}$/.test(id || "") ? id : null; } if (url.pathname.startsWith("/shorts/") || url.pathname.startsWith("/embed/")) { const parts = url.pathname.split("/").filter(Boolean); const id = parts[1]; return /^[A-Za-z0-9_-]{6,32}$/.test(id || "") ? id : null; } } } catch {} return null; } function getExactYouTubeUrl(value) { try { const url = new URL(String(value || "").trim()); const host = url.hostname.toLowerCase(); const allowed = host === "youtube.com" || host === "www.youtube.com" || host === "music.youtube.com" || host === "m.youtube.com" || host === "youtu.be" || host.endsWith(".youtube.com"); if (!allowed || !/^https?:$/.test(url.protocol)) return null; if (host === "youtu.be" && url.pathname.replace(/^\/+/, "")) return url.href; if (url.pathname === "/watch" && url.searchParams.get("v")) return url.href; } catch {} return null; } function updateBrowserTabTitle() { document.title = audioPlayer?.src && activeTrackTitle ? sanitizeTitle(activeTrackTitle) : DEFAULT_PAGE_TITLE; } function clearCurrentTrackPresentation() { activeTrackTitle = ""; currentTrackContext = null; document.title = DEFAULT_PAGE_TITLE; if (playerTitle) { playerTitle.textContent = "nothing playing"; playerTitle.removeAttribute("title"); } if (playerMeta) playerMeta.textContent = "choose a track"; } function isAudioBuffering() { if (!audioPlayer?.src || audioPlayer.ended) return false; if (audioPlayer.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) return false; try { if (Number.isFinite(audioPlayer.duration) && audioPlayer.duration > 0) { for (let i = 0; i < audioPlayer.buffered.length; i += 1) { const end = audioPlayer.buffered.end(i); if (end >= audioPlayer.duration - 0.25) return false; // fully buffered } } } catch {} return ( audioPlayer.paused || audioPlayer.seeking || audioPlayer.networkState === HTMLMediaElement.NETWORK_LOADING ); } function hasFullyBuffered() { if (!audioPlayer?.src || !Number.isFinite(audioPlayer.duration) || audioPlayer.duration <= 0) { return false; } try { for (let i = 0; i < audioPlayer.buffered.length; i += 1) { const start = audioPlayer.buffered.start(i); const end = audioPlayer.buffered.end(i); if (start <= 0 && end >= audioPlayer.duration - 0.25) { return true; } } } catch {} return false; } function updatePlayPauseButton() { if (!audioPlayer || !playPauseButton) return; const playing = !audioPlayer.paused && !audioPlayer.ended && Boolean(audioPlayer.src); const buffering = isAudioBuffering(); const seekBuffering = seekBufferingUntil > Date.now(); playPauseButton.classList.toggle("is-playing", playing); playPauseButton.classList.toggle("is-buffering", buffering || seekBuffering); playPauseButton.disabled = buffering || seekBuffering; playPauseButton.setAttribute( "aria-label", buffering || seekBuffering ? "buffering" : playing ? "pause" : "play", ); playPauseButton.title = buffering || seekBuffering ? "buffering" : playing ? "pause" : "play"; } function updateMediaSessionMetadata(item) { if (!("mediaSession" in navigator) || typeof MediaMetadata !== "function") return; try { navigator.mediaSession.metadata = new MediaMetadata({ title: sanitizeTitle(item?.title, 120), artist: sanitizeTitle(item?.channel || "-", 80), }); } catch {} } function setModeButtonState(button, enabled, label, title) { if (!button) return; button.classList.toggle("active", enabled); button.classList.toggle("inactive", !enabled); button.setAttribute("aria-pressed", String(enabled)); button.setAttribute("aria-label", label); button.title = title; } function queueIsValid() { return ( Array.isArray(playlistQueue) && playlistQueue.length > 0 && Number.isInteger(playlistQueueIndex) && playlistQueueIndex >= 0 && playlistQueueIndex < playlistQueue.length ); } function updateQueueControls() { const valid = queueIsValid(); for (const button of [prevTrackButton, nextTrackButton]) { if (!button) continue; button.disabled = !valid; button.setAttribute("aria-disabled", String(!valid)); } setModeButtonState( autoplayButton, autoplayEnabled, `autoplay ${autoplayEnabled ? "enabled" : "disabled"}`, `autoplay: ${autoplayEnabled ? "on" : "off"}` ); setModeButtonState( shuffleButton, shuffleEnabled, `shuffle ${shuffleEnabled ? "enabled" : "disabled"}`, `shuffle: ${shuffleEnabled ? "on" : "off"}` ); if (loopButton) { const enabled = loopMode !== "off"; loopButton.classList.toggle("active", enabled); loopButton.classList.toggle("inactive", !enabled); loopButton.classList.toggle("loop-one", loopMode === "one"); loopButton.setAttribute("aria-pressed", String(enabled)); loopButton.setAttribute("aria-label", loopMode === "off" ? "loop disabled" : `loop ${loopMode} enabled`); loopButton.title = `loop: ${loopMode}`; } } function renderQueue() { if (!queuePanel || !queueList) return; if (!queueIsValid()) { queuePanel.hidden = true; queueList.replaceChildren(); return; } queuePanel.hidden = false; queueList.replaceChildren(); const upcoming = []; if (shuffleEnabled) { for (let index = 0; index < playlistQueue.length; index += 1) { if (index !== playlistQueueIndex && !shufflePlayedIndexes.has(index)) upcoming.push(index); } } else { for (let index = playlistQueueIndex + 1; index < playlistQueue.length; index += 1) { upcoming.push(index); } if (loopMode === "all") { for (let index = 0; index < playlistQueueIndex; index += 1) upcoming.push(index); } } if (!upcoming.length) { queueList.innerHTML = '
no upcoming tracks
'; return; } for (const index of upcoming.slice(0, 20)) { const item = playlistQueue[index]; const button = document.createElement("button"); button.className = "queue-item"; button.type = "button"; button.innerHTML = ` ${index + 1} ${escapeHtml(item.title || "untitled")} ${escapeHtml(item.channel || "-")} · ${escapeHtml(item.duration_string || "-")} `; button.addEventListener("click", () => { playlistQueueIndex = index; shufflePlayedIndexes.add(index); updateQueueControls(); renderQueue(); playTrack(item, { fromQueue: true }); }); queueList.appendChild(button); } } function normalizeQueue() { playlistQueue = Array.isArray(playlistQueue) ? playlistQueue.filter(item => extractVideoIdFromItem(item)) : []; if (!playlistQueue.length) { playlistQueueIndex = -1; shufflePlayedIndexes.clear(); updateQueueControls(); renderQueue(); return false; } if ( !Number.isInteger(playlistQueueIndex) || playlistQueueIndex < 0 || playlistQueueIndex >= playlistQueue.length ) { playlistQueueIndex = 0; } shufflePlayedIndexes = new Set( [...shufflePlayedIndexes].filter(index => index >= 0 && index < playlistQueue.length), ); shufflePlayedIndexes.add(playlistQueueIndex); return true; } function enterSearchResultsMode() { pageRoot.classList.remove("page-home"); pageRoot.classList.add("page-results"); resultsSection.classList.remove("results-section-home"); resultsSection.classList.add("results-section-active"); searchHero.classList.add("is-compact"); } function resetHomeLayout() { pageRoot.classList.add("page-home"); pageRoot.classList.remove("page-results"); resultsSection.classList.add("results-section-home"); resultsSection.classList.remove("results-section-active"); searchHero.classList.remove("is-compact"); } function switchView(view) { currentView = view; favoritesTab.classList.toggle("active", view === "favorites"); playlistsTab.classList.toggle("active", view === "playlists" || view === "playlistDetail"); backButton.style.display = view === "search" ? "none" : ""; scrollSentinel.style.display = view === "search" ? "" : "none"; if (backButton) { backButton.hidden = view === "search"; } if (scrollSentinel) { scrollSentinel.style.display = view === "search" ? "" : "none"; } if (view !== "search") enterSearchResultsMode(); else if (currentQuery) enterSearchResultsMode(); setStatus( view === "favorites" ? "favorites" : view === "playlists" ? "playlists" : view === "playlistDetail" ? "playlist" : view === "sharedPlaylist" ? "shared playlist" : currentQuery ? `searching "${currentQuery}"` : "", ); } function resetSearchResults() { resultsBody.innerHTML = `no results`; setSentinel(""); } function appendResults(items, forceFavorited = false) { items.forEach((item, index) => { const row = document.createElement("tr"); const key = getItemKey(item); const favorited = forceFavorited || favoriteIds.has(key); const rowIndex = item.index ?? (index + 1); row.dataset.row = "result"; row.dataset.key = key; row.innerHTML = ` ${escapeHtml(rowIndex)} ${escapeHtml(item.channel || "-")} ${escapeHtml(item.duration_string || "-")} ${item.is_live ? 'live' : ""} ${ item.view_count != null ? `${escapeHtml(formatViewCount(item.view_count))}` : item.score != null ? `${escapeHtml(Number(item.score).toFixed(2))}` : "" } `; row.querySelector(".title-button")?.addEventListener("click", () => playTrack(item)); row.querySelector(".play-button")?.addEventListener("click", () => playTrack(item)); row.querySelector(".download-button")?.addEventListener("click", () => downloadTrack(item)); row.querySelector(".star-button")?.addEventListener("click", (event) => { toggleFavorite(item, row, event.currentTarget); }); row.querySelector(".playlist-add-button")?.addEventListener("click", () => addItemToPlaylist(item)); resultsBody.appendChild(row); }); } async function toggleFavorite(item, row, button) { try { const data = await apiJson("/api/favorites/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ item }), }); const key = getItemKey(item); if (data.favorited) favoriteIds.add(key); else favoriteIds.delete(key); button.textContent = data.favorited ? "★" : "☆"; button.classList.toggle("favorited", data.favorited); if (currentView === "favorites" && !data.favorited) { row.remove(); if (!resultsBody.querySelector("tr[data-row='result']")) { resultsBody.innerHTML = `no favorites yet`; } } } catch { setStatus("retry"); } } async function loadFavoriteIds() { try { favoriteIds = new Set((await apiJson("/api/favorites")).results.map(getItemKey)); } catch {} } async function startSearch(query) { searchAbortController?.abort(); enterSearchResultsMode(); currentView = "search"; currentQuery = query; currentPage = 0; loadedCount = 0; hasMore = true; isLoading = false; searchToken += 1; resultsBody.innerHTML = ``; setSentinel(""); await loadNextPage(searchToken); } async function loadNextPage(token = searchToken) { if ( currentView !== "search" || !currentQuery || !hasMore || isLoading || loadedCount >= MAX_AUTO_RESULTS || currentPage >= MAX_AUTO_PAGES || token !== searchToken ) { return; } const page = currentPage + 1; const controller = new AbortController(); searchAbortController = controller; isLoading = true; setSentinel("loading"); try { const response = await apiFetch( `/api/search?q=${encodeURIComponent(currentQuery)}&limit=${PAGE_SIZE}&page=${page}`, { signal: controller.signal }, ); if (!response.ok) throw new Error("search failed"); const data = await response.json(); if (token !== searchToken) return; if (page === 1) resultsBody.replaceChildren(); const remaining = MAX_AUTO_RESULTS - loadedCount; const items = Array.isArray(data.results) ? data.results.slice(0, remaining) : []; appendResults(items); currentPage = Number.isInteger(data.page) ? data.page : page; loadedCount += items.length; hasMore = Boolean(data.has_more) && loadedCount < MAX_AUTO_RESULTS && currentPage < MAX_AUTO_PAGES; setStatus(`${loadedCount} loaded`); if (!resultsBody.querySelector("tr[data-row='result']")) { resetSearchResults(); } setSentinel(""); } catch (error) { if (error.name !== "AbortError") { setStatus("search failed"); setSentinel(""); } } finally { if (searchAbortController === controller) searchAbortController = null; if (token === searchToken) isLoading = false; } } async function startRecommendationsSearch(url) { enterSearchResultsMode(); currentView = "search"; currentQuery = url; currentPage = 0; loadedCount = 0; hasMore = false; isLoading = true; searchToken += 1; resultsBody.innerHTML = `loading related tracks`; try { const data = await apiJson( `/api/recommendations?url=${encodeURIComponent(url)}&total=${PAGE_SIZE}&per_query=8`, ); resultsBody.replaceChildren(); if (!data.results?.length) { resetSearchResults(); setStatus("no related tracks"); } else { appendResults(data.results); setStatus(`${data.results.length} related found`); } } catch { resultsBody.innerHTML = `failed to load related`; setStatus("related tracks failed"); } finally { isLoading = false; } } async function loadFavoritesView() { enterSearchResultsMode(); currentView = "favorites"; searchToken += 1; isLoading = true; resultsBody.innerHTML = `loading favorites`; try { const data = await apiJson("/api/favorites"); resultsBody.replaceChildren(); if (data.results.length) appendResults(data.results, true); else resultsBody.innerHTML = `no favorites yet`; } catch { resultsBody.innerHTML = `error`; } finally { isLoading = false; } } async function createPlaylist() { const name = prompt("playlist name"); if (!name?.trim()) return null; return apiJson("/api/playlists", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name.trim() }), }); } async function movePlaylistItem(index, delta) { if (!currentPlaylistId) return; const nextIndex = index + delta; if (nextIndex < 0 || nextIndex >= currentPlaylistItems.length) return; const reordered = [...currentPlaylistItems]; const [moved] = reordered.splice(index, 1); reordered.splice(nextIndex, 0, moved); try { await savePlaylistOrder(currentPlaylistId, reordered); currentPlaylistItems = reordered; playlistQueue = [...reordered]; setStatus("playlist reordered"); loadPlaylistDetail(currentPlaylistId); } catch (error) { console.error(error); setStatus("reorder failed"); } } async function removePlaylistItem(playlistId, playlistItemId) { try { await apiJson(`/api/playlists/${encodeURIComponent(playlistId)}/items/${encodeURIComponent(playlistItemId)}`, { method: "DELETE", }); currentPlaylistItems = currentPlaylistItems.filter( item => item.playlist_item_id !== playlistItemId ); playlistQueue = [...currentPlaylistItems]; setStatus("removed from playlist"); loadPlaylistDetail(playlistId); } catch (error) { console.error(error); setStatus("remove failed"); } } async function loadPlaylistsView() { enterSearchResultsMode(); searchToken += 1; isLoading = true; currentPlaylistId = null; currentPlaylistItems = []; resultsBody.innerHTML = ` loading playlists `; setSentinel(""); try { const data = await apiJson("/api/playlists"); const results = data.results || []; resultsBody.innerHTML = ""; appendCreatePlaylistRow(); if (!results.length) { const row = document.createElement("tr"); row.innerHTML = `no playlists yet`; resultsBody.appendChild(row); setStatus("no playlists"); return; } results.forEach((playlist, index) => { const row = document.createElement("tr"); row.dataset.row = "playlist"; row.dataset.key = playlist.id; row.draggable = true; row.innerHTML = ` ${index + 1} ⋮⋮ ${escapeHtml(String(playlist.count || 0))} tracks ${playlist.share_url ? "shared" : "-"} `; row.querySelector(".playlist-open-button")?.addEventListener("click", () => { loadPlaylistDetail(playlist.id); }); row.querySelector(".playlist-open-button-2")?.addEventListener("click", () => { loadPlaylistDetail(playlist.id); }); row.querySelector(".playlist-rename-button")?.addEventListener("click", () => { renamePlaylist(playlist); }); row.querySelector(".playlist-share-button")?.addEventListener("click", () => { sharePlaylist(playlist); }); row.querySelector(".playlist-delete-button")?.addEventListener("click", () => { deletePlaylist(playlist); }); row.addEventListener("dragstart", (event) => { playlistReorderDragId = playlist.id; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", playlist.id); row.classList.add("dragging"); }); row.addEventListener("dragend", () => { playlistReorderDragId = null; row.classList.remove("dragging"); resultsBody.querySelectorAll("tr.drag-over").forEach(el => el.classList.remove("drag-over")); }); row.addEventListener("dragover", (event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; row.classList.add("drag-over"); }); row.addEventListener("dragleave", () => { row.classList.remove("drag-over"); }); row.addEventListener("drop", async (event) => { event.preventDefault(); row.classList.remove("drag-over"); const draggedId = event.dataTransfer.getData("text/plain") || playlistReorderDragId; if (!draggedId || draggedId === playlist.id) return; const current = [...results]; const fromIndex = current.findIndex(p => p.id === draggedId); const toIndex = current.findIndex(p => p.id === playlist.id); if (fromIndex < 0 || toIndex < 0) return; const [moved] = current.splice(fromIndex, 1); current.splice(toIndex, 0, moved); try { await apiJson("/api/playlists/reorder", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ playlist_ids: current.map(p => p.id), }), }); setStatus("playlists reordered"); loadPlaylistsView(); } catch (error) { console.error(error); setStatus("reorder failed"); } }); resultsBody.appendChild(row); }); setStatus(`${results.length} playlists`); } catch (error) { console.error(error); resultsBody.innerHTML = ` failed to load playlists `; setStatus("error"); } finally { isLoading = false; } } async function savePlaylistOrder(playlistId, items) { const itemIds = (Array.isArray(items) ? items : []) .map(item => item?.playlist_item_id) .filter(Boolean); if (!itemIds.length) { throw new Error("no playlist item ids to reorder"); } return apiJson(`/api/playlists/${encodeURIComponent(playlistId)}/items/reorder`, { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ item_ids: itemIds, }), }); } function appendPlaylistItems(playlistId, items, shared = false) { items.forEach((item, index) => { const row = document.createElement("tr"); const key = getItemKey(item); const favorited = favoriteIds.has(key); row.dataset.row = "result"; row.dataset.key = key; row.dataset.playlistItemId = item.playlist_item_id; row.draggable = !shared; row.innerHTML = ` ${index + 1} ${ shared ? "" : '⋮⋮' } ${escapeHtml(item.channel || "-")} ${escapeHtml(item.duration_string || "-")} ${item.is_live ? 'live' : ""} ${ item.view_count != null ? `${escapeHtml(formatViewCount(item.view_count))}` : item.score != null ? `${escapeHtml(Number(item.score).toFixed(2))}` : "" } ${ shared ? ` ` : ` ` } `; row.querySelector(".title-button")?.addEventListener("click", () => { playPlaylistItem(items, index); }); row.querySelector(".play-button")?.addEventListener("click", () => { playPlaylistItem(items, index); }); row.querySelector(".download-button")?.addEventListener("click", () => { downloadTrack(item); }); row.querySelector(".star-button")?.addEventListener("click", (event) => { toggleFavorite(item, row, event.currentTarget); }); if (shared) { row.querySelector(".playlist-add-button")?.addEventListener("click", () => { addItemToPlaylist(item); }); } else { row.querySelector(".playlist-remove-button")?.addEventListener("click", () => { removePlaylistItem(playlistId, item.playlist_item_id); }); const dragHandle = row.querySelector(".drag-handle"); if (dragHandle) { dragHandle.addEventListener("dragstart", (event) => { playlistItemDragId = item.playlist_item_id; playlistItemDragPlaylistId = playlistId; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", item.playlist_item_id); row.classList.add("dragging"); }); } row.addEventListener("dragstart", (event) => { if (event.target?.classList?.contains("drag-handle")) return; playlistItemDragId = item.playlist_item_id; playlistItemDragPlaylistId = playlistId; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", item.playlist_item_id); row.classList.add("dragging"); }); row.addEventListener("dragend", () => { playlistItemDragId = null; playlistItemDragPlaylistId = null; row.classList.remove("dragging"); resultsBody .querySelectorAll("tr.drag-over") .forEach(el => el.classList.remove("drag-over")); }); row.addEventListener("dragover", (event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; row.classList.add("drag-over"); }); row.addEventListener("dragleave", () => { row.classList.remove("drag-over"); }); row.addEventListener("drop", async (event) => { event.preventDefault(); row.classList.remove("drag-over"); if (playlistItemDragPlaylistId !== playlistId) return; const draggedId = event.dataTransfer.getData("text/plain") || playlistItemDragId; const targetId = item.playlist_item_id; if (!draggedId || draggedId === targetId) return; const reordered = reorderArrayByIds(items, draggedId, targetId); try { await savePlaylistOrder(playlistId, reordered); currentPlaylistItems = reordered; playlistQueue = [...reordered]; setStatus("playlist reordered"); loadPlaylistDetail(playlistId); } catch (error) { console.error(error); setStatus("reorder failed"); } }); } resultsBody.appendChild(row); }); } async function loadPlaylistDetail(playlistId, shared = false) { currentView = shared ? "sharedPlaylist" : "playlistDetail"; switchView(currentView); currentPlaylistId = shared ? null : playlistId; isLoading = true; try { const playlist = await apiJson( shared ? `/api/shared-playlists/${encodeURIComponent(playlistId)}` : `/api/playlists/${encodeURIComponent(playlistId)}` ); currentPlaylistItems = playlist.items || []; playlistQueue = [...currentPlaylistItems]; playlistQueueIndex = playlistQueue.length ? 0 : -1; shufflePlayedIndexes = new Set(); resultsBody.replaceChildren(); if (!playlistQueue.length) { resultsBody.innerHTML = `playlist is empty`; updateQueueControls(); renderQueue(); return; } appendPlaylistItems(playlistId, playlistQueue, shared); setStatus(`${playlistQueue.length} tracks`); updateQueueControls(); renderQueue(); } catch (error) { console.error(error); resultsBody.innerHTML = `failed to load playlist`; } finally { isLoading = false; } } function appendCreatePlaylistRow() { const row = document.createElement("tr"); row.innerHTML = ``; row.querySelector("button").addEventListener("click", async () => { try { if (await createPlaylist()) loadPlaylistsView(); } catch { setStatus("create playlist failed"); } }); resultsBody.appendChild(row); } async function savePlaylistOrder(playlistId, items) { const itemIds = (Array.isArray(items) ? items : []) .map(item => item?.playlist_item_id) .filter(Boolean); if (!itemIds.length) { throw new Error("no playlist item ids to reorder"); } return apiJson(`/api/playlists/${encodeURIComponent(playlistId)}/items/reorder`, { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ item_ids: itemIds, }), }); } async function choosePlaylistForItem() { const data = await apiJson("/api/playlists"); const playlists = data.results || []; if (!playlists.length) { return confirm("no playlists. create new?") ? createPlaylist() : null; } const lines = playlists.map( (playlist, index) => `${index + 1}. ${playlist.name} (${playlist.count || 0})`, ); const choice = Number(prompt( `choose playlist number:\n\n${lines.join("\n")}\n\n0. create new playlist`, )); if (choice === 0) return createPlaylist(); return Number.isInteger(choice) && choice >= 1 && choice <= playlists.length ? playlists[choice - 1] : null; } async function addItemToPlaylist(item) { try { const playlist = await choosePlaylistForItem(); if (!playlist) return; const data = await apiJson(`/api/playlists/${encodeURIComponent(playlist.id)}/items`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ item }), }); setStatus(data.duplicate ? "already in playlist" : `added to ${playlist.name}`); } catch { setStatus("add to playlist failed"); } } async function sharePlaylist(playlist) { try { const data = await apiJson(`/api/playlists/${encodeURIComponent(playlist.id)}/share`, { method: "POST", }); await navigator.clipboard?.writeText(`${window.location.origin}${data.share_url}`); setStatus("share link copied"); } catch { setStatus("share failed"); } } function setQueue(items, index = 0) { playlistQueue = Array.isArray(items) ? items.filter(item => extractVideoIdFromItem(item)) : []; playlistQueueIndex = playlistQueue.length ? Math.min(Math.max(index, 0), playlistQueue.length - 1) : -1; shufflePlayedIndexes = playlistQueueIndex >= 0 ? new Set([playlistQueueIndex]) : new Set(); updateQueueControls(); renderQueue(); } function playPlaylistItem(items, index) { setQueue(items, index); if (queueIsValid()) playTrack(playlistQueue[playlistQueueIndex], { fromQueue: true }); } function getNextQueueIndex() { if (!queueIsValid()) return -1; if (shuffleEnabled) { const available = []; for (let index = 0; index < playlistQueue.length; index += 1) { if (!shufflePlayedIndexes.has(index)) available.push(index); } if (available.length) { return available[Math.floor(Math.random() * available.length)]; } if (loopMode === "all") { shufflePlayedIndexes = new Set([playlistQueueIndex]); const loopAvailable = playlistQueue .map((_, index) => index) .filter(index => index !== playlistQueueIndex); return loopAvailable.length ? loopAvailable[Math.floor(Math.random() * loopAvailable.length)] : playlistQueueIndex; } return -1; } if (playlistQueueIndex + 1 < playlistQueue.length) { return playlistQueueIndex + 1; } return loopMode === "all" ? 0 : -1; } function playNextInQueue({ manual = false, fromEnded = false } = {}) { if (!queueIsValid()) { if (manual) setStatus("no active playlist"); updateQueueControls(); return; } if (!manual && !fromEnded && !autoplayEnabled) { setStatus("autoplay off"); return; } if (!manual && loopMode === "one") { playTrack(playlistQueue[playlistQueueIndex], { fromQueue: true }); return; } const nextIndex = getNextQueueIndex(); if (nextIndex < 0) { setStatus("playlist ended"); return; } playlistQueueIndex = nextIndex; shufflePlayedIndexes.add(nextIndex); updateQueueControls(); renderQueue(); playTrack(playlistQueue[nextIndex], { fromQueue: true }); } function playPreviousInQueue() { if (!queueIsValid()) { setStatus("no active playlist"); updateQueueControls(); return; } if (audioPlayer.currentTime > 3) { audioPlayer.currentTime = 0; setStatus("track restarted"); return; } let previousIndex = playlistQueueIndex - 1; if (previousIndex < 0) { if (loopMode !== "all") { setStatus("start of playlist"); return; } previousIndex = playlistQueue.length - 1; } playlistQueueIndex = previousIndex; shufflePlayedIndexes.add(previousIndex); updateQueueControls(); renderQueue(); playTrack(playlistQueue[previousIndex], { fromQueue: true }); } function preloadNextTrack() { if (preloadPromise || !queueIsValid()) return; const nextIndex = getNextQueueIndex(); if (nextIndex < 0) return; const videoId = extractVideoIdFromItem(playlistQueue[nextIndex]); if (!videoId) return; preloadPromise = apiJson("/api/audio-sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_id: videoId }), }) .catch(() => null) .finally(() => { preloadPromise = null; }); } function safeDownloadName(title) { return sanitizeTitle(title, 120) .replace(/[^\w.-]+/g, "_") .replace(/^_+|_+$/g, "") || "track"; } async function downloadTrack(item) { const videoId = extractVideoIdFromItem(item); if (!videoId) { setStatus("invalid track"); return; } setStatus("preparing download"); try { const response = await apiFetch( `/api/download/${encodeURIComponent(videoId)}?title=${encodeURIComponent(item.title || "untitled")}`, ); if (!response.ok) throw new Error("download failed"); const blob = await response.blob(); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = `${safeDownloadName(item.title)}.mp3`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); setStatus("download ready"); } catch { setStatus("download failed"); } } function buildPlayerStatePayload() { const queue = Array.isArray(playlistQueue) ? playlistQueue.map(sanitizeStoredTrack).filter(Boolean) : []; const currentItem = sanitizeStoredTrack(currentTrackContext?.item); return { current_item: currentItem, current_time: Number.isFinite(audioPlayer?.currentTime) ? Math.max(0, Math.min(Number(audioPlayer.currentTime.toFixed(3)), 86400)) : 0, queue, queue_index: Number.isInteger(playlistQueueIndex) && playlistQueueIndex >= 0 && playlistQueueIndex < queue.length ? playlistQueueIndex : -1, autoplay: Boolean(autoplayEnabled), shuffle: Boolean(shuffleEnabled), loop_mode: ["off", "all", "one"].includes(loopMode) ? loopMode : "off", volume: Number.isFinite(audioPlayer?.volume) ? Math.min(Math.max(audioPlayer.volume, 0), 1) : 1, queue_mode: queueMode === "recommendations" ? "recommendations" : "queue", source_url: getExactYouTubeUrl(currentQuery) || null, }; } async function savePlayerState() { if (restoringPlayerState) return; if (!audioPlayer) return; const payload = buildPlayerStatePayload(); try { await apiJson("/api/player-state", { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payload), }); } catch (error) { console.debug("player state save failed", error); } } function schedulePlayerStateSave() { if (restoringPlayerState) return; clearTimeout(playerStateSaveTimer); playerStateSaveTimer = setTimeout(() => { savePlayerState(); }, 1200); } function schedulePlayerStateSaveLowPriority() { if (restoringPlayerState) return; clearTimeout(playerStateSaveTimer); playerStateSaveTimer = setTimeout(() => { savePlayerState(); }, 3000); } async function restorePlayerState() { if (playerStateRestorePromise) return playerStateRestorePromise; playerStateRestorePromise = (async () => { restoringPlayerState = true; try { const state = await apiJson("/api/player-state"); autoplayEnabled = Boolean(state?.autoplay ?? autoplayEnabled); shuffleEnabled = Boolean(state?.shuffle ?? shuffleEnabled); loopMode = ["off", "all", "one"].includes(state?.loop_mode) ? state.loop_mode : "off"; queueMode = state?.queue_mode === "recommendations" ? "recommendations" : "queue"; if (queueModeSelect) queueModeSelect.value = queueMode; const storedVolume = Number(state?.volume); const volume = Number.isFinite(storedVolume) ? Math.min(Math.max(storedVolume, 0), 1) : 1; audioPlayer.volume = volume; if (volumeSlider) volumeSlider.value = String(Math.round(volume * 100)); localStorage.setItem(VOLUME_KEY, String(volume)); const restoredQueue = Array.isArray(state?.queue) ? state.queue.map(sanitizeStoredTrack).filter(Boolean) : []; playlistQueue = restoredQueue; playlistQueueIndex = Number.isInteger(state?.queue_index) ? state.queue_index : -1; if ( playlistQueue.length && (playlistQueueIndex < 0 || playlistQueueIndex >= playlistQueue.length) ) { playlistQueueIndex = 0; } shufflePlayedIndexes = new Set( playlistQueueIndex >= 0 ? [playlistQueueIndex] : [], ); updateQueueControlButtons?.(); const currentItem = sanitizeStoredTrack(state?.current_item); if (currentItem) { if (!playlistQueue.length) { playlistQueue = [currentItem]; playlistQueueIndex = 0; shufflePlayedIndexes = new Set([0]); } const played = await playTrack(currentItem, { fromQueue: playlistQueue.length > 0, restoreTime: Number(state?.current_time) || 0, autoplay: false, }); if (played) { audioPlayer.pause(); setStatus("restored"); } else { setStatus("restored state available"); } } } catch (error) { console.debug("player state restore failed", error); } finally { restoringPlayerState = false; updateQueueControlButtons?.(); } })(); return playerStateRestorePromise; } function updateProgress() { if (!audioPlayer) return; const duration = Number(audioPlayer.duration) || 0; seekBar.max = duration || 100; if (!seekBar.matches(":active")) { seekBar.value = audioPlayer.currentTime; } seekBar.style.setProperty( "--progress", `${duration ? (audioPlayer.currentTime / duration) * 100 : 0}%`, ); currentTimeLabel.textContent = formatTime(audioPlayer.currentTime); durationLabel.textContent = formatTime(audioPlayer.duration); } function queueControlUpdateFallback() { updateQueueControls(); renderQueue(); } async function playTrack(item, options = {}) { if (playTrackBusy) return false; playTrackBusy = true; const requestToken = ++playbackRequestToken; const fromQueue = Boolean(options.fromQueue); const restoreTime = Math.max(0, Number(options.restoreTime) || 0); const autoplay = options.autoplay !== false; try { if (!fromQueue) { playlistQueue = []; playlistQueueIndex = -1; shufflePlayedIndexes = new Set(); } const videoId = extractVideoIdFromItem(item); if (!videoId) { if (requestToken === playbackRequestToken) setStatus("invalid track"); return false; } if (requestToken !== playbackRequestToken) return false; const displayTitle = sanitizeTitle(item?.title, 120); activeTrackTitle = displayTitle; currentTrackContext = { item: { ...item, id: videoId }, fromQueue, }; playerBar?.classList.remove("hidden"); if (playerTitle) { playerTitle.textContent = displayTitle; playerTitle.title = displayTitle; } if (playerMeta) { const parts = [sanitizeTitle(item?.channel || "-", 80)]; if (item.duration_string) parts.push(String(item.duration_string)); if (fromQueue && playlistQueue.length) { parts.push(`${playlistQueueIndex + 1}/${playlistQueue.length}`); } playerMeta.textContent = parts.join(" • "); } setStatus("creating audio session"); updateQueueControlButtons?.(); updateMediaSessionMetadata(item); const session = await apiJson("/api/audio-sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_id: videoId }), }); if (requestToken !== playbackRequestToken) return false; const streamUrl = session?.streamUrl || session?.stream_url; if (!streamUrl) throw new Error("missing stream URL"); audioPlayer.src = streamUrl; audioPlayer.load(); updateBrowserTabTitle(); if (Number.isFinite(restoreTime) && restoreTime > 0) { const applyRestoreTime = () => { if (requestToken !== playbackRequestToken) return; try { if (Number.isFinite(audioPlayer.duration) && audioPlayer.duration > 0) { audioPlayer.currentTime = clampAudioTime(restoreTime); } } catch {} }; if (audioPlayer.readyState >= 1) { applyRestoreTime(); } else { audioPlayer.addEventListener("loadedmetadata", applyRestoreTime, { once: true }); } } if (!autoplay) { setStatus("ready"); schedulePlayerStateSave(); return true; } await audioPlayer.play(); if (requestToken !== playbackRequestToken) return false; setStatus("playing"); preloadNextTrack(); schedulePlayerStateSave(); return true; } catch (error) { if (requestToken !== playbackRequestToken) return false; console.error(error); audioPlayer.removeAttribute("src"); audioPlayer.load(); clearCurrentTrackPresentation(); updatePlayPauseButton(); if (error.status === 400) { setStatus("invalid video"); } else if (error.status === 404) { setStatus("session expired"); } else if (error.status === 429) { setStatus("rate limited"); } else if (error.status === 502) { setStatus("audio unavailable"); } else if (error.status === 503) { setStatus("audio capacity unavailable"); } else { setStatus("playback failed"); } return false; } finally { playTrackBusy = false; } } function installMediaSessionControls() { if (!("mediaSession" in navigator)) return; const handlers = { play: () => audioPlayer.play().catch(() => {}), pause: () => audioPlayer.pause(), previoustrack: () => playPreviousInQueue(), nexttrack: () => playNextInQueue({ manual: true }), seekbackward: details => { audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - (details.seekOffset || 10)); }, seekforward: details => { audioPlayer.currentTime += details.seekOffset || 10; }, seekto: details => { if (Number.isFinite(details.seekTime)) { audioPlayer.currentTime = details.seekTime; } }, }; for (const [action, handler] of Object.entries(handlers)) { try { navigator.mediaSession.setActionHandler(action, handler); } catch {} } } function loadSeekTheme() { const key = `${SEEK_THEME_KEY_PREFIX}:${getAnonymousId()}`; setSeekTheme(localStorage.getItem(key) || "stone", { persist: false }); } function setSeekTheme(themeName, { persist = true } = {}) { const theme = validSeekThemes.has(themeName) ? themeName : "stone"; const color = validSeekThemes.get(theme) || "#817567"; document.documentElement.style.setProperty("--seek-accent", color); document.querySelectorAll(".seek-theme-option").forEach(option => { option.setAttribute("aria-selected", String(option.dataset.seekTheme === theme)); }); if (seekThemeButton) { seekThemeButton.style.color = color; seekThemeButton.title = `seek bar color: ${theme}`; } if (persist) { localStorage.setItem(`${SEEK_THEME_KEY_PREFIX}:${getAnonymousId()}`, theme); } } searchForm?.addEventListener("submit", event => { event.preventDefault(); const query = searchInput.value.trim(); if (!query) return; const exactUrl = getExactYouTubeUrl(query); switchView("search"); if (exactUrl) startRecommendationsSearch(exactUrl); else startSearch(query); }); prevTrackButton?.addEventListener("click", playPreviousInQueue); nextTrackButton?.addEventListener("click", () => playNextInQueue({ manual: true })); autoplayButton?.addEventListener("click", () => { autoplayEnabled = !autoplayEnabled; localStorage.setItem(AUTOPLAY_KEY, String(autoplayEnabled)); updateQueueControls(); savePlayerState(); }); shuffleButton?.addEventListener("click", () => { shuffleEnabled = !shuffleEnabled; localStorage.setItem(SHUFFLE_KEY, String(shuffleEnabled)); shufflePlayedIndexes = playlistQueueIndex >= 0 ? new Set([playlistQueueIndex]) : new Set(); updateQueueControls(); renderQueue(); savePlayerState(); }); loopButton?.addEventListener("click", () => { loopMode = loopMode === "off" ? "all" : loopMode === "all" ? "one" : "off"; localStorage.setItem(LOOP_KEY, loopMode); updateQueueControls(); renderQueue(); savePlayerState(); }); playPauseButton?.addEventListener("click", () => { if (!audioPlayer.src) { setStatus("choose a track"); return; } if (audioPlayer.paused) audioPlayer.play().catch(() => setStatus("retry")); else audioPlayer.pause(); }); seekBar?.addEventListener("input", () => { audioPlayer.currentTime = Number(seekBar.value); updateProgress(); }); volumeSlider?.addEventListener("input", () => { audioPlayer.volume = Number(volumeSlider.value) / 100; localStorage.setItem(VOLUME_KEY, String(audioPlayer.volume)); savePlayerState(); }); favoritesTab?.addEventListener("click", () => { switchView("favorites"); loadFavoritesView(); }); playlistsTab?.addEventListener("click", () => { switchView("playlists"); loadPlaylistsView(); }); backButton?.addEventListener("click", () => { if (currentView === "playlistDetail") { switchView("playlists"); loadPlaylistsView(); return; } if (currentView === "sharedPlaylist") { window.location.href = "/"; return; } switchView("search"); if (currentQuery) startSearch(currentQuery); else { resetHomeLayout(); resetSearchResults(); setStatus(""); } }); resultsSection?.addEventListener( "scroll", () => { if ( currentView === "search" && hasMore && !isLoading && resultsSection.scrollHeight - resultsSection.scrollTop - resultsSection.clientHeight < 100 ) { loadNextPage(searchToken); } }, { passive: true }, ); queueRefreshButton?.addEventListener("click", () => { renderQueue(); preloadNextTrack(); }); queueModeSelect?.addEventListener("change", () => { queueMode = queueModeSelect.value === "recommendations" ? "recommendations" : "queue"; localStorage.setItem("minimal_yt_queue_mode", queueMode); savePlayerState(); }); document.querySelectorAll(".seek-theme-option").forEach(option => { option.addEventListener("click", () => { setSeekTheme(option.dataset.seekTheme); seekThemeMenu.hidden = true; }); }); seekThemeButton?.addEventListener("click", () => { seekThemeMenu.hidden = !seekThemeMenu.hidden; }); document.addEventListener("click", event => { if (seekThemeMenu && !seekThemeMenu.hidden && !event.target.closest("#seekThemePicker")) { seekThemeMenu.hidden = true; } }); document.addEventListener("keydown", event => { if ( event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || event.target.matches("input,textarea,select,[contenteditable='true']") ) { return; } const key = event.key.toLowerCase(); if (key === " " || key === "k") { event.preventDefault(); playPauseButton.click(); } else if (key === "arrowleft") { event.preventDefault(); audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 5); } else if (key === "arrowright") { event.preventDefault(); audioPlayer.currentTime += 5; } else if (event.shiftKey && key === "n") { event.preventDefault(); playNextInQueue({ manual: true }); } else if (event.shiftKey && key === "p") { event.preventDefault(); playPreviousInQueue(); } }); audioPlayer?.addEventListener("play", () => { updatePlayPauseButton(); updateBrowserTabTitle(); if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing"; savePlayerState(); }); audioPlayer?.addEventListener("pause", () => { updatePlayPauseButton(); if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "paused"; savePlayerState(); }); let seekBufferingUntil = 0; seekBar?.addEventListener("input", () => { if (!audioPlayer?.src) return; seekBufferingUntil = Date.now() + 1200; audioPlayer.currentTime = Number(seekBar.value); updateProgress(); updatePlayPauseButton(); }); audioPlayer?.addEventListener("seeking", () => { seekBufferingUntil = Date.now() + 1200; updatePlayPauseButton(); }); audioPlayer?.addEventListener("seeked", () => { seekBufferingUntil = 0; updatePlayPauseButton(); }); audioPlayer?.addEventListener("waiting", updatePlayPauseButton); audioPlayer?.addEventListener("stalled", updatePlayPauseButton); audioPlayer?.addEventListener("canplay", updatePlayPauseButton); audioPlayer?.addEventListener("canplaythrough", updatePlayPauseButton); audioPlayer?.addEventListener("playing", updatePlayPauseButton); audioPlayer?.addEventListener("pause", updatePlayPauseButton); audioPlayer?.addEventListener("loadedmetadata", updatePlayPauseButton); audioPlayer?.addEventListener("timeupdate", () => { updateProgress(); savePlayerState(); }); audioPlayer?.addEventListener("loadedmetadata", updateProgress); audioPlayer?.addEventListener("ended", () => { if (seekBar) { seekBar.value = 0; seekBar.style.setProperty("--progress", "0%"); } if (currentTimeLabel) currentTimeLabel.textContent = "0:00"; updatePlayPauseButton(); if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "none"; schedulePlayerStateSaveLowPriority(); playNextInQueue({ fromEnded: true }); }); audioPlayer?.addEventListener("error", () => { if (audioPlayer.src) setStatus("audio unavailable"); }); function clampAudioTime(value) { const duration = Number.isFinite(audioPlayer?.duration) ? audioPlayer.duration : 0; return Math.min(Math.max(Number(value) || 0, 0), duration); } function updateQueueControlButtons() { updateQueueControls(); renderQueue(); } getAnonymousId(); loadSeekTheme(); audioPlayer.volume = Number(localStorage.getItem(VOLUME_KEY) ?? 1); if (volumeSlider) volumeSlider.value = String(Math.round(audioPlayer.volume * 100)); updateQueueControls(); updatePlayPauseButton(); installMediaSessionControls(); loadFavoriteIds().finally(() => { const shareMatch = window.location.pathname.match(/^\/share\/([a-f0-9]{32})$/); if (shareMatch) loadPlaylistDetail(shareMatch[1], true); else restorePlayerState(); });