Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229<script lang="ts">import { untrack } from "svelte";import { SvelteMap, SvelteSet } from "svelte/reactivity";import { t } from "../languages/translate.svelte.js";import { loadLib } from "../library/store.svelte.js";import type { QueuedRequest } from "../offline/indexedDbStore.svelte.js";import { offlineManager, store } from "../offline/offlineManager.svelte.js";import { ui } from "../ui/state.svelte.js";import Modal from "./Modal.svelte";
const selectedIds = new SvelteSet<number>();const requestLabels = new SvelteMap<number, string>();let labelGeneration = 0;const allRequestIds = $derived( offlineManager.queuedRequests .map((req) => req.id) .filter((id): id is number => id !== undefined),);
$effect(() => { if (ui.showUnsyncedChanges) { selectedIds.clear(); for (const id of untrack(() => allRequestIds)) { selectedIds.add(id); } }});
$effect(() => { const requests = offlineManager.queuedRequests; void resolveRequestLabels(requests, ++labelGeneration);});
async function resolveRequestLabels( requests: QueuedRequest[], request: number,) { const entries = await Promise.all( requests.map(async (req) => [req.id, await getRequestLabel(req)] as const), ); if (request !== labelGeneration) return; requestLabels.clear(); for (const [id, label] of entries) { if (id !== undefined) requestLabels.set(id, label); }}
async function getRequestLabel(req: QueuedRequest) { switch (req.method) { case "star": case "unstar": return favoriteLabel( req.method === "star" ? "favorite" : "unfavorite", req.params, ); case "setRating": { const song = await store.getSong(req.params.id); return t("offline.rate", { title: song?.title || req.params.id, rating: req.params.rating, }); } case "scrobble": { const song = await store.getSong(req.params.id); return t("offline.scrobble", { title: song?.title || req.params.id, }); } case "createPlaylist": return t("offline.create_playlist", { name: req.params.name || "Unnamed", }); case "updatePlaylist": return playlistLabel("update", req.params.playlistId); case "deletePlaylist": return playlistLabel("delete", req.params.id); }}
async function favoriteLabel( actionName: "favorite" | "unfavorite", params: { id?: string; albumId?: string; artistId?: string },) { if (params.id) { const song = await store.getSong(params.id); return t(`offline.${actionName}_song`, { title: song?.title || params.id, }); } if (params.albumId) { const album = await store.getAlbum(params.albumId); return t(`offline.${actionName}_album`, { name: album?.name || params.albumId, }); } if (params.artistId) { const artist = await store.getArtist(params.artistId); return t(`offline.${actionName}_artist`, { name: artist?.name || params.artistId, }); } return "";}
async function playlistLabel(actionName: "update" | "delete", id: string) { const playlist = await store.getPlaylist(id); return t(`offline.${actionName}_playlist`, { name: playlist?.name || id });}
async function handleSync() { ui.busy = true; try { await offlineManager.executeQueueSync(Array.from(selectedIds)); await loadLib(); ui.showUnsyncedChanges = false; } finally { ui.busy = false; }}
async function handleDiscard() { const idsToDiscard = Array.from(selectedIds); if (idsToDiscard.length === 0) return; const msg = idsToDiscard.length === offlineManager.queuedRequests.length ? t("offline.discard_all_confirm") : t("offline.discard_selected_confirm", { count: idsToDiscard.length }); if (!confirm(msg)) return;
await offlineManager.discardQueuedRequests(idsToDiscard); if (offlineManager.queuedRequests.length === 0) { ui.showUnsyncedChanges = false; selectedIds.clear(); return; } selectedIds.clear(); for (const id of allRequestIds) { selectedIds.add(id); }}</script>
<Modal bind:open={ui.showUnsyncedChanges} title={t("offline.unsynced_changes")}> <div class="container"> <div> {offlineManager.isOfflineMode ? t("offline.changes_desc_offline") : t("offline.changes_desc_online")} </div> {#if offlineManager.queuedRequests.length === 0} <p>{t("offline.no_changes")}</p> {:else} <div class="list"> {#each offlineManager.queuedRequests as req} {#if req.id !== undefined} {@const id = req.id} <label> <input type="checkbox" checked={selectedIds.has(id)} onchange={(e) => e.currentTarget.checked ? selectedIds.add(id) : selectedIds.delete(id)} > <span>{requestLabels.get(id) || "..."}</span> </label> {/if} {/each} </div> {/if} <div class="actions"> <button class="native" type="button" onclick={handleDiscard} disabled={selectedIds.size === 0} > {t("offline.discard_selected")} ({selectedIds.size}) </button> {#if !offlineManager.isOfflineMode} <button class="native" type="button" onclick={handleSync} disabled={selectedIds.size === 0 || !offlineManager.online} > {t("offline.sync_selected")} ({selectedIds.size}) </button> {/if} </div> </div></Modal>
<style>.container { padding: 1rem; display: flex; flex-direction: column; gap: 1rem; min-inline-size: min(20rem, var(--viewport-inline-size)); max-inline-size: min(30rem, var(--viewport-inline-size)); max-block-size: min(80dvh, var(--viewport-block-size));}.list { display: flex; flex-direction: column; gap: 0.5rem; max-block-size: min(24rem, 50dvh, var(--viewport-block-size)); overflow-y: auto; border: var(--border-subtle); padding: 0.5rem; background-color: var(--layer); border-radius: var(--radius-sm); corner-shape: squircle;}label { display: flex; align-items: center; gap: 0.5rem;}span { min-inline-size: 0; overflow-wrap: anywhere;}</style>