s1ne/static/app,js
2026-09-09 06:29:51 +00:00

2011 lines
60 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 = '<div class="queue-empty">no upcoming tracks</div>';
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 = `
<span class="queue-item-index">${index + 1}</span>
<span class="queue-item-main">
<span class="queue-item-title">${escapeHtml(item.title || "untitled")}</span>
<span class="queue-item-meta">${escapeHtml(item.channel || "-")} · ${escapeHtml(item.duration_string || "-")}</span>
</span>
`;
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 = `<tr><td colspan="${COLSPAN}" class="empty">no results</td></tr>`;
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 = `
<td class="col-index">${escapeHtml(rowIndex)}</td>
<td class="col-title">
<button class="link-button title-button" type="button">
${escapeHtml(item.title || "untitled")}
</button>
</td>
<td class="col-channel">${escapeHtml(item.channel || "-")}</td>
<td class="col-duration">${escapeHtml(item.duration_string || "-")}</td>
<td class="col-meta result-meta">
${item.is_live ? '<span class="badge live">live</span>' : ""}
${
item.view_count != null
? `<span class="badge">${escapeHtml(formatViewCount(item.view_count))}</span>`
: item.score != null
? `<span class="badge">${escapeHtml(Number(item.score).toFixed(2))}</span>`
: ""
}
</td>
<td class="col-play">
<button class="link-button play-button" type="button">play</button>
</td>
<td class="col-download">
<button class="download-button" type="button" aria-label="download track" title="download">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M11 3h2v10.2l3.6-3.6 1.4 1.4-6 6-6-6 1.4-1.4 3.6 3.6zM4 19h16v2H4z"/>
</svg>
</button>
</td>
<td class="col-fav">
<button class="star-button ${favorited ? "favorited" : ""}" type="button" aria-label="toggle favorite">
${favorited ? "★" : "☆"}
</button>
</td>
<td class="col-playlist">
<button class="playlist-action-button playlist-add-button" type="button" aria-label="add to playlist">+</button>
</td>
`;
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 = `<tr><td colspan="${COLSPAN}" class="empty">no favorites yet</td></tr>`;
}
}
} 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 = `<tr class="loading-row"><td colspan="${COLSPAN}"></td></tr>`;
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 = `<tr class="loading-row"><td colspan="${COLSPAN}">loading related tracks</td></tr>`;
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 = `<tr><td colspan="${COLSPAN}" class="empty">failed to load related</td></tr>`;
setStatus("related tracks failed");
} finally {
isLoading = false;
}
}
async function loadFavoritesView() {
enterSearchResultsMode();
currentView = "favorites";
searchToken += 1;
isLoading = true;
resultsBody.innerHTML = `<tr class="loading-row"><td colspan="${COLSPAN}">loading favorites</td></tr>`;
try {
const data = await apiJson("/api/favorites");
resultsBody.replaceChildren();
if (data.results.length) appendResults(data.results, true);
else resultsBody.innerHTML = `<tr><td colspan="${COLSPAN}" class="empty">no favorites yet</td></tr>`;
} catch {
resultsBody.innerHTML = `<tr><td colspan="${COLSPAN}" class="empty">error</td></tr>`;
} 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 = `
<tr class="loading-row">
<td colspan="${COLSPAN}">loading playlists</td>
</tr>
`;
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 = `<td colspan="${COLSPAN}" class="empty">no playlists yet</td>`;
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 = `
<td class="col-index">${index + 1}</td>
<td class="col-title">
<span class="drag-handle" draggable="true" aria-label="drag to reorder">⋮⋮</span>
<button class="link-button playlist-open-button" type="button">
${escapeHtml(playlist.name)}
</button>
</td>
<td class="col-channel">${escapeHtml(String(playlist.count || 0))} tracks</td>
<td class="col-duration">${playlist.share_url ? "shared" : "-"}</td>
<td class="col-meta">
<button class="link-button playlist-rename-button" type="button">rename</button>
</td>
<td class="col-play">
<button class="link-button playlist-open-button-2" type="button">open</button>
</td>
<td class="col-download"></td>
<td class="col-fav">
<button class="playlist-action-button playlist-share-button" type="button" aria-label="share playlist">↗</button>
</td>
<td class="col-playlist">
<button class="playlist-action-button playlist-delete-button" type="button" aria-label="delete playlist">×</button>
</td>
`;
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 = `
<tr><td colspan="${COLSPAN}" class="empty">failed to load playlists</td></tr>
`;
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 = `
<td class="col-index">${index + 1}</td>
<td class="col-title">
${
shared
? ""
: '<span class="drag-handle" draggable="true" aria-label="drag to reorder">⋮⋮</span>'
}
<button class="link-button title-button" type="button">
${escapeHtml(item.title || "untitled")}
</button>
</td>
<td class="col-channel">${escapeHtml(item.channel || "-")}</td>
<td class="col-duration">${escapeHtml(item.duration_string || "-")}</td>
<td class="col-meta result-meta">
${item.is_live ? '<span class="badge live">live</span>' : ""}
${
item.view_count != null
? `<span class="badge">${escapeHtml(formatViewCount(item.view_count))}</span>`
: item.score != null
? `<span class="badge">${escapeHtml(Number(item.score).toFixed(2))}</span>`
: ""
}
</td>
<td class="col-play">
<button class="link-button play-button" type="button">play</button>
</td>
<td class="col-download">
<button class="download-button" type="button" aria-label="download audio" title="download audio">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M11 3h2v10.2l3.6-3.6 1.4 1.4-6 6-6-6 1.4-1.4 3.6 3.6zM4 19h16v2H4z"></path>
</svg>
</button>
</td>
<td class="col-fav">
<button class="star-button ${favorited ? "favorited" : ""}" type="button" aria-label="toggle favorite">
${favorited ? "★" : "☆"}
</button>
</td>
<td class="col-playlist">
${
shared
? `
<button class="playlist-action-button playlist-add-button" type="button" aria-label="add to playlist">+</button>
`
: `
<button class="playlist-action-button playlist-remove-button" type="button" aria-label="remove from playlist">×</button>
`
}
</td>
`;
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 = `<tr><td colspan="${COLSPAN}" class="empty">playlist is empty</td></tr>`;
updateQueueControls();
renderQueue();
return;
}
appendPlaylistItems(playlistId, playlistQueue, shared);
setStatus(`${playlistQueue.length} tracks`);
updateQueueControls();
renderQueue();
} catch (error) {
console.error(error);
resultsBody.innerHTML = `<tr><td colspan="${COLSPAN}" class="empty">failed to load playlist</td></tr>`;
} finally {
isLoading = false;
}
}
function appendCreatePlaylistRow() {
const row = document.createElement("tr");
row.innerHTML = `<td colspan="${COLSPAN}"><button class="link-button create-playlist-button" type="button">create playlist</button></td>`;
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();
});