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