utils.ts
⎇
Raw
1import { type Accessor, createEffect, createSignal, onMount, type Setter } from "solid-js";
2import Toastify from "toastify-js";
3import { FileDisplay, type PlaylistItem } from "./types";
4
5//a boolean toggle that restores itself from localStorage on mount and persists every later change
6export function persistedToggle(key: string, fallback: boolean): [Accessor<boolean>, Setter<boolean>] {
7 const [value, setValue] = createSignal(fallback);
8 onMount(() => {
9 const saved = localStorage.getItem(key);
10 if (saved !== null) setValue(saved === "true");
11 createEffect(() => localStorage.setItem(key, value().toString()));
12 });
13 return [value, setValue];
14}
15
16//join string and dedup slashes and make sure it doesn't start or end with a slash
17export function joinPath(...paths: string[]): string {
18 return paths.join("/").replace(/\/+/g, "/").replace(/^\//, "").replace(/\/$/, "");
19}
20
21//dirname("a/b/c") = "a/b", dirname("a") = ""
22export function dirname(path: string): string {
23 return path.split("/").slice(0, -1).join("/");
24}
25
26export function basename(path: string): string {
27 return path.split("/").toReversed()[0];
28}
29
30//yields each ancestor directory, immediate parent first: parentPaths("a/b/c") yields "a/b", "a"
31export function* parentPaths(path: string): Generator<string> {
32 let current = path;
33 while (true) {
34 const lastSlash = current.lastIndexOf("/");
35 if (lastSlash === -1) return;
36 current = current.substring(0, lastSlash);
37 yield current;
38 }
39}
40
41//directories sort before files, names compare naturally ("track 2" before "track 10")
42export function compareDirFirst(aName: string, aIsDir: boolean, bName: string, bIsDir: boolean): number {
43 if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
44 return aName.localeCompare(bName, undefined, { numeric: true });
45}
46
47export function formatTime(seconds: number): string {
48 const mins = Math.floor(seconds / 60);
49 const secs = Math.floor(seconds % 60);
50 return `${mins}:${secs < 10 ? "0" : ""}${secs}`;
51}
52
53export const LocalStorageValues = {
54 options: "Options",
55 volume: "Volume",
56 playlist: "Playlist",
57 shuffle: "Shuffle",
58 repeat: "Repeat",
59 offline: "Offline",
60 path: "Path",
61 fileBrowserButtons: "FileBrowserButtons",
62 playlistButtons: "PlaylistButtons",
63 playerControls: "PlayerControls",
64};
65
66function stringErr(e: unknown) {
67 return e && typeof e === "object" && "message" in e ? e.message : e;
68}
69
70export function toast(text: string, type: "progress" | "info" | "error" | "success", e?: unknown) {
71 if (e) {
72 console.error(e);
73 }
74 const thisToast = Toastify({
75 text: e ? `${text}:\n${stringErr(e)}` : text,
76 duration: type === "progress" ? -1 : 2000,
77 close: true,
78 style: {
79 "border-radius": "0",
80 display: "flex",
81 "align-items": "center",
82 background:
83 type === "success"
84 ? "linear-gradient(135deg,rgb(38, 194, 27),rgb(5, 144, 52))"
85 : type === "error"
86 ? "linear-gradient(135deg,rgb(255, 24, 24),rgb(220, 0, 0))"
87 : "linear-gradient(135deg,rgb(50, 60, 255),rgb(34, 37, 209))",
88 },
89 });
90 thisToast.showToast();
91 return thisToast;
92}
93
94export function remoteLog(e: unknown) {
95 //absolute path and a catch, so the error logger can't itself throw an unhandled rejection while offline
96 fetch("/remote-log", { body: JSON.stringify(e?.toString()), method: "POST", credentials: "same-origin" }).catch(
97 () => {},
98 );
99}
100
101//debounce so a burst of failing requests (e.g. many covers loading at once) shows a single notification
102let lastUnauthorized = 0;
103
104//registered by the app root so a 401 anywhere can update the logged-in state
105let onUnauthorized: (() => void) | undefined;
106export function setUnauthorizedHandler(handler: () => void) {
107 onUnauthorized = handler;
108}
109
110export function handleUnauthorized(response?: Response | { status?: number } | void): boolean {
111 if (response?.status !== 401) return false;
112 //not debounced: keeps the logged-in state correct even while the toast is throttled
113 onUnauthorized?.();
114 const now = Date.now();
115 if (now - lastUnauthorized > 3000) {
116 lastUnauthorized = now;
117 toast("Unauthorized, please sign in", "error");
118 }
119 return true;
120}
121
122//registered by the app root so a listing the user may not see resets navigation to the top level
123let onForbiddenPath: (() => void) | undefined;
124export function setForbiddenPathHandler(handler: () => void) {
125 onForbiddenPath = handler;
126}
127
128export function handleForbiddenPath(response?: Response | { status?: number } | void): boolean {
129 if (response?.status !== 403) return false;
130 onForbiddenPath?.();
131 toast("That folder is not available to you, returning to the top level", "error");
132 return true;
133}
134
135//HTTP/2 has no reason phrase, so statusText alone is empty behind most TLS proxies
136export function statusLabel(response: Response): string {
137 return response.statusText ? `${response.status} ${response.statusText}` : `HTTP ${response.status}`;
138}
139
140export function formatBytes(bytes: number): string {
141 if (bytes === 0) return "Queued...";
142
143 const k = 1024;
144 const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
145 const i = Math.floor(Math.log(bytes) / Math.log(k));
146 const value = bytes / k ** i;
147
148 return `${value.toFixed(2)} ${sizes[i]}`;
149}
150
151export function formatFilename(file: Omit<PlaylistItem, "id">, format: FileDisplay): string {
152 switch (format) {
153 case FileDisplay.FILENAME:
154 return basename(file.path);
155 case FileDisplay.TITLE:
156 return file.metadata.title || basename(file.path);
157 case FileDisplay.TRACKNO_TITLE: {
158 if (!file.metadata.title) return basename(file.path);
159 const prefix = file.metadata.trackNumber ? `${file.metadata.trackNumber}. ` : "";
160 return `${prefix}${file.metadata.title}`;
161 }
162 }
163 throw new Error(`unhandled: ${format}`);
164}
165