"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();
});