PlaylistManager.tsx
⎇
Raw
1import { type Accessor, createEffect, createResource, createSignal, For, type Setter, Show } from "solid-js";
2import "./PlaylistManager.css";
3import "./Dialog.css";
4import "toastify-js/src/toastify.css";
5import { deletePlaylist, loadPlaylists, savePlaylist } from "../offline";
6import type { AppOptions, Playlist, PlaylistItem } from "../types";
7import { formatFilename, formatTime, generateUUID, toast } from "../utils";
8import { LoadingScreen } from "./LoadingScreen";
9import { ClearSVG, DeleteSVG, DownloadSVG, UploadSVG } from "./SVG";
10
11interface PlaylistManagerProps {
12 showPlaylistManager: Accessor<boolean>;
13 setShowPlaylistManager: Setter<boolean>;
14 playlist: Accessor<PlaylistItem[]>;
15 setPlaylist: Setter<PlaylistItem[]>;
16 playTrack: (track: PlaylistItem) => void;
17 options: AppOptions;
18}
19
20export default function PlaylistManager(props: PlaylistManagerProps) {
21 const [playlists, fetchPlaylists] = createResource<Playlist[]>(
22 async () => {
23 return await loadPlaylists();
24 },
25 { initialValue: [] },
26 );
27 const [loadingActions, setLoadingActions] = createSignal(0);
28 let dialogElement!: HTMLDialogElement;
29
30 createEffect(() => {
31 if (props.showPlaylistManager()) {
32 dialogElement.showModal();
33 fetchPlaylists.refetch();
34 }
35 });
36
37 function handleClose() {
38 dialogElement.close();
39 props.setShowPlaylistManager(false);
40 }
41
42 function getTotalDuration(playlist: Playlist) {
43 if (playlist.items.length === 0) return 0;
44 //for some reason "duration" is a string at runtime...
45 return playlist.items
46 .map((item) => Number.parseFloat(item.metadata.duration as unknown as string))
47 .reduce((a, b) => a + b);
48 }
49
50 function exportPlaylists() {
51 toast("Exporting playlists...", "info");
52 const blob = new Blob([JSON.stringify(playlists())], { type: "application/json" });
53
54 const link = document.createElement("a");
55 link.href = URL.createObjectURL(blob);
56 link.download = "pico-playlists.json";
57 document.body.appendChild(link);
58 link.click();
59 document.body.removeChild(link);
60 URL.revokeObjectURL(link.href);
61 }
62
63 function importPlaylists() {
64 const input = document.createElement("input");
65 input.type = "file";
66
67 input.onchange = async (event) => {
68 const target = event.target as HTMLInputElement;
69 const file = target.files?.[0];
70 if (file) {
71 try {
72 toast("Importing playlists...", "info");
73 setLoadingActions((prev) => prev + 1);
74 const decoder = new TextDecoder();
75 const imports = JSON.parse(decoder.decode(await file.bytes())) as Playlist[];
76 for (const imported of imports) {
77 await savePlaylist(imported, true);
78 }
79 setLoadingActions((prev) => prev - 1);
80 fetchPlaylists.refetch();
81 } catch (e) {
82 toast("Importing playlists failed", "error", e);
83 }
84 }
85 };
86 input.click();
87 }
88
89 function generateIds(items: Omit<PlaylistItem, "id">[]): PlaylistItem[] {
90 return items.map((item) => ({ ...item, id: generateUUID() }));
91 }
92
93 return (
94 <dialog ref={dialogElement} onClose={handleClose} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
95 <div>
96 <div class="playlistmanager-header title">
97 Playlists
98 <div class="spacer" />
99 <button title="Export playlists" onClick={exportPlaylists}>
100 <DownloadSVG />
101 </button>
102 <button title="Import playlists" onClick={importPlaylists}>
103 <UploadSVG />
104 </button>
105 <button title="Close dialog" onClick={handleClose}>
106 <ClearSVG />
107 </button>
108 </div>
109 <hr />
110 <div class="playlistmanager-content">
111 <Show when={playlists.loading || loadingActions() > 0}>
112 <LoadingScreen />
113 </Show>
114 <Show when={!playlists.loading}>
115 <For each={playlists()}>
116 {(item) => {
117 const [showSongs, setShowSongs] = createSignal(false);
118 return (
119 <div class="playlistmanager-item">
120 <span>{item.name}</span>
121 <hr />
122 <div class="playlistmanager-buttons">
123 <span>
124 {item.items.length} Items ({formatTime(getTotalDuration(item))})
125 </span>
126 <button
127 title="Show playlist items"
128 onClick={() => {
129 setShowSongs(!showSongs());
130 }}
131 >
132 Show
133 </button>
134 <button
135 title="Replace current playlist with this one"
136 onClick={() => {
137 const playlist = generateIds(item.items);
138 props.setPlaylist(playlist);
139 if (item.items.length > 0) props.playTrack(playlist[0]);
140 handleClose();
141 }}
142 >
143 Play
144 </button>
145 <button
146 title="Add items from this playlist to current one"
147 onClick={() => props.setPlaylist(props.playlist().concat(generateIds(item.items)))}
148 >
149 Queue
150 </button>
151 <button
152 title="Delete playlist"
153 onClick={async () => {
154 setLoadingActions((prev) => prev + 1);
155 try {
156 await deletePlaylist(item.name);
157 fetchPlaylists.refetch();
158 } finally {
159 setLoadingActions((prev) => prev - 1);
160 }
161 }}
162 >
163 <DeleteSVG />
164 </button>
165 </div>
166 <Show when={showSongs()}>
167 <For each={item.items}>
168 {(item) => {
169 return (
170 <>
171 {formatFilename(item, props.options.playlistDisplay)}
172 <br />
173 </>
174 );
175 }}
176 </For>
177 </Show>
178 </div>
179 );
180 }}
181 </For>
182 </Show>
183 </div>
184 </div>
185 </dialog>
186 );
187}
188