Playlist.tsx
⎇
Raw
1import { createSelector, type Accessor, type Setter } from "solid-js";
2import "./Playlist.css";
3import { getItemName, type PlayerState, type PlaylistItem } from "../types";
4import { formatTime, toast } from "../utils";
5import { ClearSVG, DownloadSVG, DragSVG, SaveSVG } from "./SVG";
6import { savePlaylist } from "../offline";
7import { DraggableList } from "./DraggableList";
8
9interface PlaylistProps {
10 currentDir: Accessor<string>;
11 playlist: Accessor<PlaylistItem[]>;
12 setPlaylist: Setter<PlaylistItem[]>;
13 playerState: Accessor<PlayerState>;
14 setPlayerState: Setter<PlayerState>;
15 playTrack: (item: PlaylistItem) => void;
16 audioElement: HTMLAudioElement;
17}
18
19export default function Playlist(props: PlaylistProps) {
20 function removeFromPlaylist(id: string) {
21 const currentTrack = props.playerState().currentTrack;
22 const newPlaylist = props.playlist().filter((i) => i.id !== id);
23 if (currentTrack && currentTrack.id === id) {
24 const idx = props.playlist().findIndex((i) => i.id === id);
25 if (newPlaylist.length > 0) {
26 props.playTrack(idx < newPlaylist.length ? newPlaylist[idx] : newPlaylist[0]);
27 }
28 }
29 props.setPlaylist(newPlaylist);
30 }
31
32 function browserDownload(name: string, path: string) {
33 const a = document.createElement("a");
34 a.href = `/download/${path}`;
35 a.download = name;
36 document.body.appendChild(a);
37 a.click();
38 document.body.removeChild(a);
39 }
40
41 async function browserDownloadAll() {
42 try {
43 toast("Started playlist download", "info");
44 const playlist = props.playlist();
45 const filePaths = playlist.map((item) => item.path);
46 const form = document.createElement("form");
47 form.method = "POST";
48 form.action = "/download-playlist";
49 const input = document.createElement("input");
50 input.type = "hidden";
51 input.name = "files";
52 input.value = btoa(encodeURIComponent(JSON.stringify(filePaths)));
53 form.appendChild(input);
54 document.body.appendChild(form);
55 form.submit();
56 document.body.removeChild(form);
57 } catch (e) {
58 toast("Playlist items download failed", "error", e);
59 }
60 }
61
62 let dialogElement!: HTMLDialogElement;
63 let playlistNameInput!: HTMLInputElement;
64 let allowOverwriteCheckbox!: HTMLInputElement;
65
66 async function save() {
67 try {
68 if (!playlistNameInput.reportValidity()) return;
69 await savePlaylist({ name: playlistNameInput.value, items: props.playlist() }, allowOverwriteCheckbox.checked);
70 closeDialog();
71 } catch (e) {
72 if (e instanceof Error && e.name === "ConstraintError") {
73 toast("Playlist with this name already exists", "error");
74 } else {
75 toast("Saving playlist failed", "error", e);
76 }
77 }
78 }
79
80 function closeDialog() {
81 dialogElement.close();
82 playlistNameInput.value = "";
83 allowOverwriteCheckbox.checked = false;
84 }
85
86 return (
87 <div class="playlist-container">
88 <dialog class="save-dialog" ref={dialogElement}>
89 <div>
90 <input ref={playlistNameInput} required type="text" placeholder="Playlist name" />
91 <div class="save-buttons">
92 <label for="overwrite-checkbox">allow overwrite</label>
93 <input ref={allowOverwriteCheckbox} id="overwrite-checkbox" type="checkbox" />
94 <button onClick={save}>Save</button>
95 <button onClick={closeDialog}>Cancel</button>
96 </div>
97 </div>
98 </dialog>
99
100 <div class="playlist-header">
101 <span>Playlist ({props.playlist().length} tracks)</span>
102 <div class="spacer" />
103 <button title="Save playlist" onClick={() => dialogElement.showModal()}>
104 <SaveSVG />
105 </button>
106 <button
107 title="Download all files in the playlist in their original format as a tar file"
108 onClick={browserDownloadAll}
109 >
110 <DownloadSVG />
111 </button>
112 <button title="Clear Playlist" onClick={() => props.setPlaylist([])}>
113 <ClearSVG />
114 </button>
115 </div>
116
117 <DraggableList<PlaylistItem> items={props.playlist} setItems={props.setPlaylist}>
118 {(item, _, { dragHandlers, isDragging, dropPosition }) => {
119 const isActive = createSelector(
120 () => props.playerState().currentTrack?.id,
121 (itemId, currentId) => currentId === itemId,
122 );
123 return (
124 <div
125 classList={{
126 "playlist-item": true,
127 "draggable-item": true,
128 active: isActive(item.id),
129 "drop-target-top-border": dropPosition() === "before",
130 "drop-target-bottom-border": dropPosition() === "after",
131 }}
132 style={{ opacity: isDragging() ? 0.5 : 1 }}
133 onClick={() => props.playTrack(item)}
134 >
135 <div class="playlist-drag-btn" {...dragHandlers}>
136 <DragSVG />
137 </div>
138 <span>{getItemName(item)}</span>
139 <div class="spacer" />
140 {formatTime(item.metadata.duration)}
141 <button
142 title={"Download this file in its original format to your file system"}
143 onClick={(e) => {
144 e.stopPropagation();
145 browserDownload(getItemName(item), item.path);
146 }}
147 >
148 <DownloadSVG />
149 </button>
150 <button
151 title={`Remove ${getItemName(item)}`}
152 onClick={(e) => {
153 e.stopPropagation();
154 removeFromPlaylist(item.id);
155 }}
156 >
157 <ClearSVG />
158 </button>
159 </div>
160 );
161 }}
162 </DraggableList>
163 </div>
164 );
165}
166