A music player that connects to your cloud/distributed storage.
1import { html, render as litRender } from "lit-html";
2
3import * as Output from "~/common/output.js";
4import foundation from "~/common/foundation.js";
5import { effect, signal } from "~/common/signal.js";
6
7import { SCHEME as SCHEME_DROPBOX } from "~/components/input/dropbox/constants.js";
8import { SCHEME as SCHEME_EPHEMERAL_CACHE } from "~/components/input/ephemeral-cache/constants.js";
9import { SCHEME as SCHEME_HTTPS } from "~/components/input/https/constants.js";
10import { SCHEME as SCHEME_ICECAST } from "~/components/input/icecast/constants.js";
11import { SCHEME as SCHEME_LOCAL } from "~/components/input/local/constants.js";
12import { SCHEME as SCHEME_OPENSUBSONIC } from "~/components/input/opensubsonic/constants.js";
13import { SCHEME as SCHEME_S3 } from "~/components/input/s3/constants.js";
14
15/** @type {Record<string, string>} */
16const SCHEME_NAMES = {
17 [SCHEME_DROPBOX]: "Dropbox",
18 [SCHEME_EPHEMERAL_CACHE]: "Browser storage",
19 [SCHEME_HTTPS]: "HTTPS",
20 [SCHEME_ICECAST]: "Icecast",
21 [SCHEME_LOCAL]: "Local directories & files",
22 [SCHEME_OPENSUBSONIC]: "OpenSubsonic",
23 [SCHEME_S3]: "S3",
24};
25
26foundation.setup({ title: "Sources | Diffuse" });
27
28////////////////////////////////////////////
29// SETUP
30////////////////////////////////////////////
31
32const [
33 inputConfigurator,
34 sourcesOrchestrator,
35 outputOrchestrator,
36 processOrchestrator,
37] = await Promise.all([
38 foundation.configurator.input(),
39 foundation.orchestrator.sources(),
40 foundation.orchestrator.output(),
41 foundation.orchestrator.processTracks({ disableWhenReady: true }),
42]);
43
44await Promise.all([
45 customElements.whenDefined(inputConfigurator.localName),
46 customElements.whenDefined(sourcesOrchestrator.localName),
47 customElements.whenDefined(outputOrchestrator.localName),
48]);
49
50////////////////////////////////////////////
51// PROCESS BUTTON
52////////////////////////////////////////////
53
54const processBtn =
55 /** @type {HTMLButtonElement} */ (document.querySelector("#process-btn"));
56const processIcon =
57 /** @type {HTMLElement} */ (document.querySelector("#process-icon"));
58const processLabel =
59 /** @type {HTMLElement} */ (document.querySelector("#process-label"));
60
61effect(() => {
62 const isProcessing = processOrchestrator.isProcessing();
63 const { processed, total } = processOrchestrator.progress();
64 const pct = total > 0 ? Math.round((processed / total) * 100) : null;
65
66 processBtn.disabled = isProcessing;
67 processIcon.className = isProcessing
68 ? "ph-fill ph-arrows-clockwise animate-spin"
69 : "ph-fill ph-arrows-clockwise";
70 processLabel.textContent = isProcessing
71 ? (pct !== null ? `Processing (${pct}%)` : "Listing")
72 : "Process";
73});
74
75processBtn.addEventListener("click", async () => {
76 const output = await foundation.orchestrator.output();
77 await Output.data(output.tracks);
78 await processOrchestrator.process();
79});
80
81////////////////////////////////////////////
82// UI
83////////////////////////////////////////////
84
85const list =
86 /** @type {HTMLElement} */ (document.querySelector("#sources-list"));
87const empty =
88 /** @type {HTMLElement} */ (document.querySelector("#sources-empty"));
89
90/** @param {string} uri */
91const trackPrefix = (uri) => {
92 const q = uri.indexOf("?");
93 return q === -1 ? uri : uri.slice(0, q);
94};
95
96////////////////////////////////////////////
97// ONLINE STATUS
98////////////////////////////////////////////
99
100
101const onlineMap = signal(/** @type {Record<string, boolean | null>} */ ({}));
102
103/** @param {{ [scheme: string]: import("~/components/input/types.d.ts").Source[] }} sourcesRecord */
104async function checkOnlineStatus(sourcesRecord) {
105 const sources = Object.values(sourcesRecord).flat();
106 const entries = await Promise.all(
107 sources.map(async ({ uri }) => {
108 const result = await inputConfigurator.consult(uri);
109 const online =
110 result.supported && result.consult !== "undetermined"
111 ? result.consult
112 : null;
113 return /** @type {[string, boolean | null]} */ ([trackPrefix(uri), online]);
114 }),
115 );
116 onlineMap.value = Object.fromEntries(entries);
117}
118
119effect(() => {
120 checkOnlineStatus(sourcesOrchestrator.sources());
121});
122
123effect(() => {
124 const sourcesRecord = sourcesOrchestrator.sources();
125 const statusMap = onlineMap.get();
126
127 const tracksCol = outputOrchestrator.tracks.collection();
128 const tracks = tracksCol.state === "loaded" ? tracksCol.data : [];
129
130 /** @param {string} uri */
131 const statusClass = (uri) => {
132 const status = statusMap[trackPrefix(uri)];
133 if (status === true) return "sources-item__status--online";
134 if (status === false) return "sources-item__status--offline";
135 return "sources-item__status--unknown";
136 };
137
138 /** @param {string} uri */
139 const statusTitle = (uri) => {
140 const status = statusMap[trackPrefix(uri)];
141 if (status === true) return "Online";
142 if (status === false) return "Offline";
143 return "Status unknown";
144 };
145
146 const entries = Object.entries(sourcesRecord).filter(
147 ([, sources]) => sources.length > 0,
148 );
149
150 list.hidden = entries.length === 0;
151 empty.hidden = entries.length > 0;
152
153 litRender(
154 html`
155 ${entries.map(([scheme, sources]) => {
156 if (scheme === SCHEME_EPHEMERAL_CACHE) {
157 const uri = `${SCHEME_EPHEMERAL_CACHE}://`;
158 const isDisabled = sourcesOrchestrator.isDisabled(uri);
159 const trackCount = tracks.filter((t) => t.uri.startsWith(uri)).length;
160 return html`
161 <li class="sources-scheme">${SCHEME_NAMES[scheme] ?? scheme}</li>
162 <li class="sources-item ${isDisabled
163 ? "sources-item--disabled"
164 : ""}">
165 <div class="sources-item__info">
166 <span class="sources-item__name">Files stored in the browser</span>
167 <span class="sources-item__detail">
168 <span class="sources-item__status ${statusClass(uri)}" title="${statusTitle(uri)}"></span>
169 ${trackCount} track${trackCount === 1 ? "" : "s"}
170 </span>
171 </div>
172 <button
173 class="button--plain"
174 title="${isDisabled ? "Enable source" : "Disable source"}"
175 @click="${() => sourcesOrchestrator.toggle(uri)}"
176 >
177 <i class="ph-fill ${isDisabled
178 ? "ph-eye-slash"
179 : "ph-eye"}"></i>
180 </button>
181 <button
182 class="button--plain button--icon"
183 title="Remove source"
184 @click="${() => removeEphemeralSources()}"
185 >
186 <i class="ph-fill ph-skull"></i>
187 </button>
188 </li>
189 `;
190 }
191
192 return html`
193 <li class="sources-scheme">${SCHEME_NAMES[scheme] ?? scheme}</li>
194 ${sources.map(({ label, uri }) => {
195 const isDisabled = sourcesOrchestrator.isDisabled(uri);
196 const trackCount = tracks.filter((t) =>
197 t.uri.startsWith(trackPrefix(uri))
198 ).length;
199 return html`
200 <li class="sources-item ${isDisabled
201 ? "sources-item--disabled"
202 : ""}">
203 <div class="sources-item__info">
204 <span class="sources-item__name">${label}</span>
205 <span class="sources-item__detail">
206 <span class="sources-item__status ${statusClass(uri)}" title="${statusTitle(uri)}"></span>
207 ${trackCount} track${trackCount === 1 ? "" : "s"}
208 </span>
209 </div>
210 <button
211 class="button--plain button--icon"
212 title="${isDisabled ? "Enable source" : "Disable source"}"
213 @click="${() => sourcesOrchestrator.toggle(uri)}"
214 >
215 <i class="ph-fill ${isDisabled
216 ? "ph-eye-slash"
217 : "ph-eye"}"></i>
218 </button>
219 <button
220 class="button--plain button--icon"
221 title="Remove source"
222 @click="${() => removeSource(uri)}"
223 >
224 <i class="ph-fill ph-skull"></i>
225 </button>
226 </li>
227 `;
228 })}
229 `;
230 })}
231 `,
232 list,
233 );
234});
235
236////////////////////////////////////////////
237// ACTIONS
238////////////////////////////////////////////
239
240async function removeEphemeralSources() {
241 return removeSource(SCHEME_EPHEMERAL_CACHE);
242}
243
244/** @param {string} uri */
245async function removeSource(uri) {
246 const tracks = await Output.data(outputOrchestrator.tracks);
247
248 const detachedTracks = await inputConfigurator.detach({
249 fileUriOrScheme: uri,
250 tracks,
251 });
252
253 if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks);
254}
255
256////////////////////////////////////////////
257// 🚀
258////////////////////////////////////////////
259
260foundation.ready();