diff --git a/static/app,js b/static/app,js new file mode 100644 index 0000000..bafabbf --- /dev/null +++ b/static/app,js @@ -0,0 +1,2010 @@ +"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(); +});