1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169 |
x1
x1
x1
x1
x1
x1
x2
x1
x2
x2
x2
x2
x2
x2
x2
x2
x2
x2
x2
x2
x2
x2
x2
x1
x1
x2
x2
x2
x2
x2
x2
x2
x1
x2
x2
x1
x19
x19
x4
x4
x19
x19
x1
x1
x2
x18
x18
x1
x2
x11
x10
x10
x11
x19
x19
x19
x10
x11
x1
x2
x5
x5
x5
x1
x2
x6
x4
x4
x4
x4
x6
x6
x1
x2
x9
x9
x9
x9
x1
x2
x5
x5
x1
x1
x2
x14
x14
x12
x14
x2
x14
x14
x1
x1
x2
x73
x73
x73
x1
x2
x32
x32
x1
x2
x26
x26
x26
x4
x2
x2
x26
x5
x5
x25
x2
x26
x1
x2
x8
x8
x2
x2
x8
x3
x3
x3
x8
x1
x2
x3
x3
x1
x2
x3
x3
x1
x2
x4
x4
x1
x1
x2
x6
x6
x6
x2
x6
x1
x2
x15
x15
x1
x2
x3
x3
x3
x1
x2
x10
x10
x10
x10
x10
x10
x10
x1
x2
x2
x2 |
|
// Pure helpers for the interface: formatting and display rules for conversions.
// No DOM access, so they run in `deno test tests/js`.
//
// Functions that produce text take an `i18n` context: `{ t, exists, locale }`, where `t` and
// `exists` are bound to the current language and `locale` is its code ("en", "fr").
export const ACTIVE = new Set(["queued", "matching", "downloading", "converting"]);
const SOURCE_ICONS = {
"YouTube": "bi-youtube",
"YouTube Music": "bi-youtube",
"Spotify": "bi-spotify",
"SoundCloud": "bi-soundwave",
"Bandcamp": "bi-vinyl-fill",
"Mixcloud": "bi-cloud-fill",
"Vimeo": "bi-vimeo",
"Twitch": "bi-twitch",
"TikTok": "bi-tiktok",
"Instagram": "bi-instagram",
"Facebook": "bi-facebook",
"X": "bi-twitter-x",
"Reddit": "bi-reddit",
};
// States whose text does not depend on the job's data.
const STATUS_KEYS = {
queued: "status.queued",
matching: "status.matching",
converting: "status.converting",
cancelled: "status.cancelled",
expired: "status.expired",
};
const SIZE_UNITS = ["B", "kB", "MB", "GB"];
const decimalFormats = new Map();
function decimal(locale) {
if (!decimalFormats.has(locale)) {
decimalFormats.set(locale, new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }));
}
return decimalFormats.get(locale);
}
/** Links found in pasted or shared text (there can be several, one per line for instance). */
export function findUrls(text) {
return (text ?? "").match(/(?:https?:\/\/|spotify:)[^\s<>"]+/g) ?? [];
}
export function formatBytes(bytes, i18n) {
if (bytes == null) return "";
let value = bytes;
let unit = 0;
while (value >= 1000 && unit < SIZE_UNITS.length - 1) {
value /= 1000;
unit++;
}
return i18n.t("units.size", { value: decimal(i18n.locale).format(value), unit: i18n.t(`units.${SIZE_UNITS[unit]}`) });
}
export function formatPercent(fraction, i18n) {
// Rounded down: 100 % only shows once the download is really over.
return i18n.t("units.percent", { value: Math.floor(Math.min(fraction, 1) * 100) });
}
export function formatDuration(seconds) {
if (!seconds) return "";
const total = Math.round(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = String(total % 60).padStart(2, "0");
return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
}
export function formatTtl(seconds, i18n) {
const minutes = Math.round(seconds / 60);
const [key, count] = minutes >= 60 ? ["units.hours", minutes / 60] : ["units.minutes", minutes];
return i18n.t(key, { count, value: decimal(i18n.locale).format(count) });
}
export function formatTime(timestamp, locale) {
return new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(timestamp);
}
/** Text for an error coming from the server (`{ code, message }`) or raised by the interface. */
export function errorText(error, i18n) {
if (!error) return "";
if (typeof error === "string") return error; // lists saved before errors had codes
const key = `errors.${error.code}`;
if (i18n.exists(key)) return i18n.t(key, error.values);
// Unknown code (a raw yt-dlp message for instance): the server's English message.
return error.message || i18n.t("errors.unexpected");
}
/** Displayed state: the server's, except for a finished file whose time is up. */
export function jobState(job, now) {
const expired = job.status === "done" && job.expires_at && job.expires_at * 1000 < now;
return expired ? "expired" : job.status;
}
export function isActive(job) {
return ACTIVE.has(job.status);
}
export function statusText(job, now, i18n) {
const state = jobState(job, now);
if (state in STATUS_KEYS) return i18n.t(STATUS_KEYS[state]);
if (state === "downloading") {
if (job.progress == null) return i18n.t("status.downloading");
return i18n.t("status.downloadingPercent", { percent: formatPercent(job.progress, i18n) });
}
if (state === "done") {
return job.size ? i18n.t("status.doneSize", { size: formatBytes(job.size, i18n) }) : i18n.t("status.done");
}
if (state === "error") return job.error ? errorText(job.error, i18n) : i18n.t("status.failed");
return "";
}
export function detailText(job, now, i18n) {
const state = jobState(job, now);
if (state === "downloading" && job.speed) {
return i18n.t("detail.speed", { speed: formatBytes(job.speed, i18n) });
}
if (state === "done" && job.expires_at) {
return i18n.t("detail.until", { time: formatTime(job.expires_at * 1000, i18n.locale) });
}
return "";
}
export function subtitle(job) {
return [job.artist, job.album, formatDuration(job.duration)].filter(Boolean).join(", ");
}
export function sourceIcon(job) {
return SOURCE_ICONS[job.source] ?? "bi-globe2";
}
export function sourceLabel(job, i18n) {
return job.source === "Spotify" ? i18n.t("track.spotifySource") : job.source;
}
/** Stroke offset of the progress ring (a circle whose length is 100). */
export function ringOffset(job, now) {
const state = jobState(job, now);
if (state === "done") return 0;
if (state === "downloading" && job.progress != null) return 100 * (1 - Math.min(job.progress, 1));
return 100;
}
export function readyIds(group, now) {
return group.jobs.filter((j) => jobState(j, now) === "done").map((j) => j.id);
}
export function archiveUrl(group, now) {
const name = encodeURIComponent(group.playlist ?? "Platine");
return `/api/archive?ids=${readyIds(group, now).join(",")}&name=${name}`;
}
export function groupSummary(group, now, i18n) {
const total = group.jobs.length;
const ready = readyIds(group, now).length;
return i18n.t("group.summary", {
tracks: i18n.t("group.tracks", { count: total }),
ready: ready === total ? i18n.t("group.allReady", { count: total }) : i18n.t("group.ready", { count: ready }),
});
}
export function bookmarklet(origin) {
return `javascript:location.href='${origin}/?url='+encodeURIComponent(location.href)`;
}
|