import { type Accessor, batch, createEffect, createResource, createSelector, createSignal, on, onMount, type ResourceActions, type Setter, Show, } from "solid-js"; import "./Playlist.css"; import type { FlatFileListing } from "../App"; import { download, getCover, savePlaylist, storedFilesLive } from "../offline"; import type { AppOptions, PlayerState, PlaylistItem } from "../types"; import { formatFilename, formatTime, LocalStorageValues, toast } from "../utils"; import { DraggableList } from "./DraggableList"; import { LoadingScreen } from "./LoadingScreen"; import { ClearSVG, DownloadSVG, DownSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG, UpSVG } from "./SVG"; interface PlaylistProps { playlist: Accessor; setPlaylist: Setter; playerState: PlayerState; setPlayerState: Setter; playTrack: (item: PlaylistItem) => void; playNext: () => void; videoElement: (el: HTMLVideoElement) => void; videoShown: Accessor; options: AppOptions; fetchFiles: ResourceActions; isOffline: Accessor; } export default function Playlist(props: PlaylistProps) { const [showActionButtons, setShowActionButtons] = createSignal(true); //TODO: add a way to refetch cover const [cover] = createResource( () => ({ filePath: props.playerState.currentTrack?.path, offline: props.isOffline() }), async (args, info) => { if (info.value) URL.revokeObjectURL(info.value as string); if (!args.filePath) return undefined; return await getCover(args.filePath, props.options, false, args.offline); }, { initialValue: undefined }, ); function removeFromPlaylist(id: string) { const newPlaylist = props.playlist().filter((i) => i.id !== id); if (id === props.playerState.currentTrack?.id && newPlaylist.length > 0) props.playNext(); props.setPlaylist(newPlaylist); } async function playlistDownload(download: boolean) { try { const playlist = props.playlist(); const filePaths = playlist.map((item) => item.path); const response = await fetch("/prepare-playlist", { body: JSON.stringify(filePaths), method: "POST", credentials: "same-origin", }); if (!response.ok) throw new Error(`Generating playlist id not successful: ${await response.text()}`); const id = await response.text(); if (download) { toast("Started playlist download", "info"); const a = document.createElement("a"); a.href = `/download-playlist/${id}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); } else { toast("Copied playlist link to clipboard", "info"); const urlPath = location.href.split(/\?|#/)[0]; navigator.clipboard.writeText(`${urlPath}${urlPath.endsWith("/") ? "" : "/"}download-playlist/${id}`); } } catch (e) { toast("Playlist items download failed", "error", e); } } const [syncing, setSyncing] = createSignal(false); async function syncPlaylist() { if (syncing()) return; setSyncing(true); const progressToast = toast("Started playlist sync", "info"); try { const itemsToSync: PlaylistItem[] = []; const playlist = props.playlist(); const stored = new Set(storedFilesLive().map(String)); //dedup by path (playlist items are always fresh objects, so reference dedup was a no-op) const seen = new Set(); for (const item of playlist) { if (seen.has(item.path) || stored.has(item.path)) continue; seen.add(item.path); itemsToSync.push(item); } let downloadPromises!: Promise[]; batch(() => { downloadPromises = itemsToSync.map((item) => download(item.path, item, props.options, props.fetchFiles)); }); await Promise.allSettled(downloadPromises); toast("Playlist sync completed", "success"); } finally { progressToast.hideToast(); setSyncing(false); } } let dialogElement!: HTMLDialogElement; let playlistNameInput!: HTMLInputElement; let allowOverwriteCheckbox!: HTMLInputElement; async function save() { try { if (!playlistNameInput.reportValidity()) return; await savePlaylist({ name: playlistNameInput.value, items: props.playlist() }, allowOverwriteCheckbox.checked); closeDialog(); } catch (e) { if (e instanceof Error && e.name === "ConstraintError") { toast("Playlist with this name already exists", "error"); } else { toast("Saving playlist failed", "error", e); } } } function closeDialog() { dialogElement.close(); playlistNameInput.value = ""; allowOverwriteCheckbox.checked = false; } const [syncedCount, setSyncedCount] = createSignal(0); createEffect( on([props.playlist, storedFilesLive], ([playlist, storedFiles]) => { const stored = new Set(storedFiles.map(String)); setSyncedCount(playlist.filter((item) => stored.has(item.path)).length); }), ); onMount(() => { const savedPlaylistButtons = localStorage.getItem(LocalStorageValues.playlistButtons); if (savedPlaylistButtons !== null) setShowActionButtons(savedPlaylistButtons === "true"); createEffect(() => localStorage.setItem(LocalStorageValues.playlistButtons, showActionButtons().toString())); }); return (
e.target.nodeName === "DIALOG" && closeDialog()}>
Playlist ({props.playlist().length} tracks, {syncedCount()} synced)
items={props.playlist} setItems={props.setPlaylist} id="playlist"> {(item, _, { dragHandlers, isDragging, dropPosition }) => { const isActive = createSelector( () => props.playerState.currentTrack?.id, (itemId, currentId) => currentId === itemId, ); return (
props.playTrack(item)} title={item.path} >
{formatFilename(item, props.options.playlistDisplay)}
{formatTime(item.metadata.duration)}
); }}
); }