2011 lines
60 KiB
Plaintext
2011 lines
60 KiB
Plaintext
"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();
|
||
});
|