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.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267<script lang="ts">import { getSourceApi, isRadio } from "../client.svelte.js";import { t } from "../languages/translate.svelte.js";import { lib, loadPlaylists } from "../library.svelte.js";import { queue } from "../queue.svelte.js";import { sources } from "../sources.svelte.js";import { ui } from "../ui.svelte.js";import Modal from "./Modal.svelte";
let exportName = $state("");let isExporting = $state(false);let isUpdating = $state(false);let isRenaming = $state(false);let isDeleting = $state(false);
const queueSourceIds = $derived( new Set( queue.tracks .filter((track) => !isRadio(track)) .map((track) => track.sourceId || sources.activeId) .filter((sourceId): sourceId is string => !!sourceId), ),);const mixedQueue = $derived(queueSourceIds.size > 1);const playlistSourceId = $derived( ui.playlistForm && "sourceId" in ui.playlistForm ? ui.playlistForm.sourceId : undefined,);const queueSourceId = $derived( [...queueSourceIds][0] || playlistSourceId || sources.activeId,);const sourceMismatch = $derived( !!playlistSourceId && !!queueSourceId && playlistSourceId !== queueSourceId,);const cannotSaveQueue = $derived(mixedQueue || sourceMismatch);
async function doExport() { if (!exportName.trim() || cannotSaveQueue || !queueSourceId) return; isExporting = true; try { await getSourceApi(queueSourceId).createPlaylist( exportName.trim(), queue.tracks.filter((track) => !isRadio(track)).map((track) => track.id), ); await loadPlaylists(queueSourceId); ui.playlistForm = null; exportName = ""; } catch (err) { console.error(err); alert(t("message.failed_save")); } finally { isExporting = false; }}
let countdown = $state(0);
$effect(() => { if (ui.playlistForm?.mode === "update" || ui.confirmDeletePlaylist) { countdown = 2; const interval = setInterval(() => { countdown--; if (countdown <= 0) clearInterval(interval); }, 1000); return () => clearInterval(interval); }});
async function doUpdate() { const playlistForm = ui.playlistForm; if (playlistForm?.mode !== "update" || cannotSaveQueue) return; isUpdating = true; try { const sourceApi = getSourceApi(playlistForm.sourceId); const songIds = queue.tracks .filter((track) => !isRadio(track)) .map((track) => track.id); if (songIds.length > 0) { await sourceApi.createPlaylist(undefined, songIds, playlistForm.id); } else { const curSongs = await sourceApi.getPlaylist(playlistForm.id); if (curSongs.length > 0) { await sourceApi.updatePlaylist( playlistForm.id, undefined, undefined, curSongs.map((_, i: number) => i), ); } } ui.lastUpdatedPlaylistId = `${playlistForm.id}:${Date.now()}`; ui.playlistForm = null; } catch (err) { console.error(err); alert(t("message.failed_save")); } finally { isUpdating = false; }}
function canRename() { return ui.playlistForm?.mode === "rename" && !!ui.playlistForm.name.trim();}
async function doRename() { const playlistForm = ui.playlistForm; if (playlistForm?.mode !== "rename" || !playlistForm.name.trim()) return; isRenaming = true; try { const { id } = playlistForm; const name = playlistForm.name.trim(); await getSourceApi(playlistForm.sourceId).updatePlaylist(id, name); const playlist = lib.playlists.find( (item) => item.id === id && item.sourceId === playlistForm.sourceId, ); if (playlist) playlist.name = name; ui.playlistForm = null; } catch (err) { console.error(err); alert(t("message.failed_rename")); } finally { isRenaming = false; }}
async function doDelete() { const playlist = ui.confirmDeletePlaylist; if (!playlist) return; isDeleting = true; try { const sourceId = playlist.sourceId; await getSourceApi(sourceId).deletePlaylist(playlist.id); await loadPlaylists(sourceId); ui.confirmDeletePlaylist = null; } catch (err) { console.error(err); alert(t("message.failed_delete")); } finally { isDeleting = false; }}</script>
{#if ui.playlistForm?.mode === "new"} <Modal open={true} onclose={() => (ui.playlistForm = null)} title={t("action.new_playlist")} > <section class="modal-section"> {#if mixedQueue} <p class="warn">{t("message.mixed_servers_playlist")}</p> {:else if sourceMismatch} <p class="warn">{t("message.playlist_server_mismatch")}</p> {/if} <div class="field"> <label for="pl-name">{t("common.name")}</label> <input id="pl-name" bind:value={exportName} placeholder={t("action.new_playlist")} onkeydown={(e) => e.key === "Enter" && !isExporting && exportName.trim() && doExport()} > </div> <div class="actions"> <button class="native" type="button" onclick={doExport} disabled={!exportName.trim() || isExporting || cannotSaveQueue} > {isExporting ? t("common.busy") : t("common.export")} </button> </div> </section> </Modal>{:else if ui.playlistForm?.mode === "update"} <Modal open={true} onclose={() => (ui.playlistForm = null)} title={t("action.update_playlist")} > <section class="modal-section"> <p>{t("message.update_confirm", { name: ui.playlistForm.name })}</p> <p class="warn">{t("message.undo_warning")}</p> {#if mixedQueue} <p class="warn">{t("message.mixed_servers_playlist")}</p> {:else if sourceMismatch} <p class="warn">{t("message.playlist_server_mismatch")}</p> {/if} <div class="actions"> <button class="native" type="button" onclick={doUpdate} disabled={isUpdating || countdown > 0 || cannotSaveQueue} > {isUpdating ? t("common.busy") : countdown > 0 ? `${t("common.update")} (${countdown})` : t("common.update")} </button> </div> </section> </Modal>{:else if ui.playlistForm?.mode === "rename"} <Modal open={true} onclose={() => (ui.playlistForm = null)} title={t("action.rename_playlist")} > <section class="modal-section"> <div class="field"> <label for="pl-rename-name">{t("common.name")}</label> <input id="pl-rename-name" bind:value={ui.playlistForm.name} onkeydown={(e) => e.key === "Enter" && !isRenaming && canRename() && doRename()} > </div> <div class="actions"> <button class="native" type="button" onclick={doRename} disabled={isRenaming || !canRename()} > {isRenaming ? t("common.busy") : t("common.rename")} </button> </div> </section> </Modal>{/if}
{#if ui.confirmDeletePlaylist} <Modal open={true} onclose={() => (ui.confirmDeletePlaylist = null)} title={t("action.delete_playlist")} > <section class="modal-section"> <p> {t("message.delete_confirm", { name: ui.confirmDeletePlaylist.name })} </p> <p class="warn">{t("message.undo_warning")}</p> <div class="actions"> <button class="native" type="button" onclick={doDelete} disabled={isDeleting || countdown > 0} > {isDeleting ? t("common.busy") : countdown > 0 ? `${t("common.delete")} (${countdown})` : t("common.delete")} </button> </div> </section> </Modal>{/if}