nixamp 0.57.1 → 0.58.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channels.d.ts +20 -0
- package/dist/channels.js +38 -0
- package/dist/guide-rooms.d.ts +42 -0
- package/dist/guide-rooms.js +157 -7
- package/dist/radio-pages.d.ts +2 -2
- package/dist/radio-pages.js +5 -5
- package/dist/server.js +52 -1
- package/dist/station-routes.d.ts +9 -1
- package/dist/station-routes.js +63 -3
- package/package.json +1 -1
- package/src/channels.ts +43 -0
- package/src/guide-rooms.ts +182 -8
- package/src/radio-pages.ts +5 -5
- package/src/server.ts +52 -1
- package/src/station-routes.ts +69 -4
- package/web/dist/assets/{ar-HteUbQ_m.js → ar-CBn9fObz.js} +1 -1
- package/web/dist/assets/{cs-CjN_4DAJ.js → cs-BvEScz92.js} +1 -1
- package/web/dist/assets/{da-CzsSXVfk.js → da-uKElVtgi.js} +1 -1
- package/web/dist/assets/{de-D7zVt1UT.js → de-DXEoI1GG.js} +1 -1
- package/web/dist/assets/{es-D8duNQiq.js → es-Q-QEZFh2.js} +1 -1
- package/web/dist/assets/{fi-CSKCYE5E.js → fi-ClJkdfXs.js} +1 -1
- package/web/dist/assets/{fr-DnjuIwaN.js → fr-DWmNoP1h.js} +1 -1
- package/web/dist/assets/{hi-ByZe82O5.js → hi-DGQOrveg.js} +1 -1
- package/web/dist/assets/{hu-B3iwlAuW.js → hu-DGcKM8DB.js} +1 -1
- package/web/dist/assets/{id-B58CS7rH.js → id-D18Cz5RW.js} +1 -1
- package/web/dist/assets/{it-CaVLbop_.js → it-WQxuRjlI.js} +1 -1
- package/web/dist/assets/{main-yTRNIxIl.js → main-cry6C8KD.js} +2 -2
- package/web/dist/assets/{nl-Dk0ss8Tf.js → nl-BvOA9QBr.js} +1 -1
- package/web/dist/assets/{ru-CGuA41ji.js → ru-Cx4tgP1k.js} +1 -1
- package/web/dist/assets/{station-CSnjPeod.css → station-6b_smJHe.css} +1 -1
- package/web/dist/assets/station-BzLUgyQ0.js +3 -0
- package/web/dist/assets/{sv-B309Td6Z.js → sv-f0zj1AdU.js} +1 -1
- package/web/dist/assets/{uk-CCCKvR-U.js → uk-DpGYtFFI.js} +1 -1
- package/web/dist/assets/{vi-DfVTbK_t.js → vi-Cxf7VqJQ.js} +1 -1
- package/web/dist/assets/watch-Bygo4bPY.js +1 -0
- package/web/dist/assets/{zh-BU9_CD0f.js → zh-DyuYbygO.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +2 -2
- package/web/dist/sw.js +24 -24
- package/web/dist/vod.html +2 -2
- package/web/dist/watch.html +2 -2
- package/web/dist/assets/station-D6sd4DqP.js +0 -3
- package/web/dist/assets/watch-Dxsz5gHd.js +0 -1
- /package/web/dist/assets/{vod-Br9Ooz6-.js → vod-Bbg_gWbl.js} +0 -0
package/package.json
CHANGED
package/src/channels.ts
CHANGED
|
@@ -282,6 +282,21 @@ export interface ChannelOptions {
|
|
|
282
282
|
through?: ThroughProvider;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Where each chunk a channel put out begins in its byte count (ChannelInfo.bytes),
|
|
287
|
+
* so a listener's own bytes can be laid onto the channel's: a backlog, a
|
|
288
|
+
* skipped break and a redial all keep the chunk objects they were sent as.
|
|
289
|
+
*/
|
|
290
|
+
const chunkStarts = new WeakMap<Buffer, number>();
|
|
291
|
+
|
|
292
|
+
/** The byte count a chunk began at, for one a list channel put out; undefined for any other. */
|
|
293
|
+
export function chunkStart(chunk: Buffer): number | undefined {
|
|
294
|
+
return chunkStarts.get(chunk);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A list's dial: where in the byte count it began, and its list's clock. */
|
|
298
|
+
interface Dial { base: number; input: PlaylistInputStream; kbps: number }
|
|
299
|
+
|
|
285
300
|
/**
|
|
286
301
|
* One live source, and its audience.
|
|
287
302
|
*
|
|
@@ -338,6 +353,20 @@ export class Channel {
|
|
|
338
353
|
return this.info.bytes > 0 && (!this.fragments || this.fragments.ready);
|
|
339
354
|
}
|
|
340
355
|
|
|
356
|
+
/**
|
|
357
|
+
* What was going out at this point of the byte count: the list's file (""
|
|
358
|
+
* in a break) and how far into it, with its tags. Null for a byte this
|
|
359
|
+
* channel no longer knows, or for a channel that is not a radio list.
|
|
360
|
+
*/
|
|
361
|
+
placeAt(bytes: number): { file: string; position: number; tags?: { title: string; artist: string; album: string } } | null {
|
|
362
|
+
const dial = this.dials.findLast((one) => one.base <= bytes);
|
|
363
|
+
if (!dial) return null;
|
|
364
|
+
const place = dial.input.place(((bytes - dial.base) * 8) / (dial.kbps * 1000));
|
|
365
|
+
const file = place.at >= 0 ? place.list[place.at] ?? "" : "";
|
|
366
|
+
const tags = file ? dial.input.tags.get(file) : undefined;
|
|
367
|
+
return { file, position: place.position, ...(tags ? { tags } : {}) };
|
|
368
|
+
}
|
|
369
|
+
|
|
341
370
|
/** An entry's tags, as read when it was put on. */
|
|
342
371
|
tagsOf(file: string): { title: string; artist: string; album: string } | undefined {
|
|
343
372
|
return this.playlistInput?.tags.get(file);
|
|
@@ -369,6 +398,8 @@ export class Channel {
|
|
|
369
398
|
private breakOn = false;
|
|
370
399
|
/** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
|
|
371
400
|
private music: Buffer[] = [];
|
|
401
|
+
/** The last few dials of a radio list, newest last, for placeAt(). */
|
|
402
|
+
private dials: Dial[] = [];
|
|
372
403
|
private musicBytes = 0;
|
|
373
404
|
private fragmentClock = new FragmentClock();
|
|
374
405
|
private fragmentTimes = new WeakMap<Buffer, number>();
|
|
@@ -622,9 +653,16 @@ export class Channel {
|
|
|
622
653
|
if (this.child !== child) return;
|
|
623
654
|
if (!sent) this.info.error = undefined;
|
|
624
655
|
sent = true;
|
|
656
|
+
const at = this.info.bytes;
|
|
625
657
|
this.info.bytes += chunk.byteLength;
|
|
626
658
|
this.rearm(child);
|
|
627
659
|
if (kbps > 0 && playlistInput) {
|
|
660
|
+
// Constant-bitrate MP3: a byte of this dial is exactly a time on its list's clock.
|
|
661
|
+
if (written === 0) {
|
|
662
|
+
this.dials.push({ base: at, input: playlistInput, kbps });
|
|
663
|
+
if (this.dials.length > 8) this.dials.shift();
|
|
664
|
+
}
|
|
665
|
+
chunkStarts.set(chunk, at);
|
|
628
666
|
this.breakOn = playlistInput.place((written * 8) / (kbps * 1000)).at === -1;
|
|
629
667
|
written += chunk.byteLength;
|
|
630
668
|
} else this.breakOn = this.info.playlistAt === -1;
|
|
@@ -1460,6 +1498,11 @@ export class Channels {
|
|
|
1460
1498
|
return this.open.get(id)?.info.kind;
|
|
1461
1499
|
}
|
|
1462
1500
|
|
|
1501
|
+
/** What a radio list channel was putting out at a point of its byte count (Channel.placeAt). */
|
|
1502
|
+
placeAt(id: string, bytes: number): ReturnType<Channel["placeAt"]> {
|
|
1503
|
+
return this.open.get(id)?.placeAt(bytes) ?? null;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1463
1506
|
/** An entry's tags on a channel playing a list of files. */
|
|
1464
1507
|
tagsOf(id: string, file: string): { title: string; artist: string; album: string } | undefined {
|
|
1465
1508
|
return this.open.get(id)?.tagsOf(file);
|
package/src/guide-rooms.ts
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* a Share or a "?play=" link carries, `https://host:port/api/channels/<id>?k=KEY`
|
|
6
6
|
* or `https://host:port/api/live?k=KEY`. A nixamp.com member publishes one with
|
|
7
7
|
* a rights basis, as a station is published; the guide lists it beside the
|
|
8
|
-
* stations
|
|
9
|
-
*
|
|
8
|
+
* stations; its card opens /room/<name>, which plays it as a film or as
|
|
9
|
+
* radio, whichever it carries.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* /room/<name> is the room under a readable name on this server, which asks
|
|
12
|
+
* the room's own server for it with the key held here: the page, its state,
|
|
13
|
+
* the stream and HLS all come from this origin, so no page names the machine
|
|
14
|
+
* the room is on or carries its key. The old /watch?play=<address> links
|
|
15
|
+
* still play, straight from the room's server.
|
|
13
16
|
*/
|
|
14
17
|
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
15
18
|
import { join } from "node:path";
|
|
@@ -17,6 +20,7 @@ import { randomBytes } from "node:crypto";
|
|
|
17
20
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
18
21
|
import { readConfig } from "./library.ts";
|
|
19
22
|
import type { GuideEntry } from "./station-guide.ts";
|
|
23
|
+
import { slugify } from "./radio-slug.ts";
|
|
20
24
|
|
|
21
25
|
/** Why a room may be broadcast, the same five reasons a station gives. */
|
|
22
26
|
export const ROOM_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"] as const;
|
|
@@ -74,6 +78,38 @@ export function watchBase(fallback: string, env: Record<string, string | undefin
|
|
|
74
78
|
|
|
75
79
|
export const watchPage = (base: string, play: string): string => `${base}/watch?play=${encodeURIComponent(play)}`;
|
|
76
80
|
|
|
81
|
+
/** A room's page under its name, on this server. */
|
|
82
|
+
export const roomPage = (base: string, slug: string): string => `${base}/room/${slug}`;
|
|
83
|
+
|
|
84
|
+
export const ROOM_PAGE = /^\/room\/([A-Za-z0-9_-]{1,60})\/?$/;
|
|
85
|
+
export const ROOM_SUB = /^\/room\/([a-z0-9-]{1,60})\/(state|stream|hls\/[A-Za-z0-9._-]{1,64})$/;
|
|
86
|
+
|
|
87
|
+
export function isRoomPath(path: string): boolean {
|
|
88
|
+
return ROOM_PAGE.test(path) || ROOM_SUB.test(path);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The rooms by name: "Dropkick Murphys" is /room/dropkick-murphys. Oldest
|
|
93
|
+
* first, so a name stays with the room that had it; a second one is -2.
|
|
94
|
+
*/
|
|
95
|
+
export function roomSlugs(rooms: GuideRoom[]): Map<string, GuideRoom> {
|
|
96
|
+
const out = new Map<string, GuideRoom>();
|
|
97
|
+
const sorted = [...rooms].sort((a, b) => (a.publishedAt - b.publishedAt) || a.id.localeCompare(b.id));
|
|
98
|
+
for (const room of sorted) {
|
|
99
|
+
const base = slugify(room.name) || "room";
|
|
100
|
+
let slug = base;
|
|
101
|
+
for (let n = 2; out.has(slug); n += 1) slug = `${base}-${n}`;
|
|
102
|
+
out.set(slug, room);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A room's name here, or "". */
|
|
108
|
+
export function roomSlugOf(slugs: Map<string, GuideRoom>, id: string): string {
|
|
109
|
+
for (const [slug, one] of slugs) if (one.id === id) return slug;
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
|
|
77
113
|
export class RoomStore {
|
|
78
114
|
private rooms: GuideRoom[] = [];
|
|
79
115
|
constructor(private readonly file: string) {
|
|
@@ -128,6 +164,9 @@ export interface RoomState {
|
|
|
128
164
|
name: string;
|
|
129
165
|
kind: "audio" | "video";
|
|
130
166
|
nowPlaying: { title: string; artist: string } | null;
|
|
167
|
+
/** A folder or a playlist (more than one file): HLS plays it better than the endless MP4. */
|
|
168
|
+
folder?: boolean;
|
|
169
|
+
album?: string;
|
|
131
170
|
}
|
|
132
171
|
|
|
133
172
|
interface ChannelRow {
|
|
@@ -135,7 +174,8 @@ interface ChannelRow {
|
|
|
135
174
|
name?: string;
|
|
136
175
|
kind?: string;
|
|
137
176
|
live?: boolean;
|
|
138
|
-
|
|
177
|
+
playlist?: unknown[];
|
|
178
|
+
codecs?: { tags?: { title?: string; artist?: string; album?: string } };
|
|
139
179
|
}
|
|
140
180
|
|
|
141
181
|
/** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
|
|
@@ -175,6 +215,8 @@ export class RoomProbe {
|
|
|
175
215
|
name: String(row.name ?? address.channel).replace(/\/+$/, "").slice(0, 120),
|
|
176
216
|
kind: row.kind === "video" ? "video" : "audio",
|
|
177
217
|
nowPlaying: tags && (tags.title || tags.artist) ? { title: String(tags.title ?? ""), artist: String(tags.artist ?? "") } : null,
|
|
218
|
+
folder: address.channel.startsWith("folder-") || (Array.isArray(row.playlist) && row.playlist.length > 1),
|
|
219
|
+
album: String(tags?.album ?? "").slice(0, 200),
|
|
178
220
|
};
|
|
179
221
|
}
|
|
180
222
|
// The live stream: on the air when it answers with media.
|
|
@@ -185,7 +227,7 @@ export class RoomProbe {
|
|
|
185
227
|
const type = answer.headers.get("content-type") ?? "";
|
|
186
228
|
controller.abort();
|
|
187
229
|
if (!answer.ok) return null;
|
|
188
|
-
return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null };
|
|
230
|
+
return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null, folder: false, album: "" };
|
|
189
231
|
} finally {
|
|
190
232
|
clearTimeout(timer);
|
|
191
233
|
}
|
|
@@ -194,6 +236,7 @@ export class RoomProbe {
|
|
|
194
236
|
|
|
195
237
|
/** The rooms as guide cards: the page to open, on the air or not, and what is on. */
|
|
196
238
|
export async function roomEntries(store: RoomStore, probe: RoomProbe, base: string): Promise<GuideEntry[]> {
|
|
239
|
+
const slugs = roomSlugs(store.list());
|
|
197
240
|
return Promise.all(store.list().map(async (room) => {
|
|
198
241
|
const state = await probe.state(room.play);
|
|
199
242
|
return {
|
|
@@ -202,7 +245,7 @@ export async function roomEntries(store: RoomStore, probe: RoomProbe, base: stri
|
|
|
202
245
|
kind: state?.kind ?? room.kind,
|
|
203
246
|
group: "rooms" as const,
|
|
204
247
|
state: state?.live ? "live" : "offline",
|
|
205
|
-
page:
|
|
248
|
+
page: roomPage(base, roomSlugOf(slugs, room.id) || room.id),
|
|
206
249
|
nowPlaying: state?.nowPlaying ?? null,
|
|
207
250
|
};
|
|
208
251
|
}));
|
|
@@ -281,7 +324,7 @@ export async function handleRooms(request: IncomingMessage, response: ServerResp
|
|
|
281
324
|
const title = typeof body?.title === "string" ? body.title.trim().slice(0, 120) : "";
|
|
282
325
|
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
|
|
283
326
|
const room = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, basis, note, by: taken?.by ?? by });
|
|
284
|
-
ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page:
|
|
327
|
+
ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: roomPage(ctx.base, roomSlugOf(roomSlugs(ctx.store.list()), room.id) || room.id) });
|
|
285
328
|
return true;
|
|
286
329
|
}
|
|
287
330
|
const one = /^\/api\/v1\/guide\/rooms\/([A-Za-z0-9_-]{1,32})$/.exec(path);
|
|
@@ -298,3 +341,134 @@ export async function handleRooms(request: IncomingMessage, response: ServerResp
|
|
|
298
341
|
ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
|
|
299
342
|
return true;
|
|
300
343
|
}
|
|
344
|
+
|
|
345
|
+
// --- /room/<name>: the room through this server ----------------------------------
|
|
346
|
+
|
|
347
|
+
export interface RoomProxyContext {
|
|
348
|
+
store: RoomStore;
|
|
349
|
+
probe: RoomProbe;
|
|
350
|
+
/** Why an address may not be fetched from this machine, or "" (as the probe's). */
|
|
351
|
+
refuse: (link: string) => Promise<string>;
|
|
352
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
353
|
+
fetcher?: typeof fetch;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** What /room/<name>/state answers: the room as its page shows it, no address or key. */
|
|
357
|
+
export interface RoomView {
|
|
358
|
+
name: string;
|
|
359
|
+
kind: "audio" | "video";
|
|
360
|
+
live: boolean;
|
|
361
|
+
folder: boolean;
|
|
362
|
+
hls: boolean;
|
|
363
|
+
title: string;
|
|
364
|
+
artist: string;
|
|
365
|
+
album: string;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The room's origin and key, gone from a text answer (a playlist, an error). */
|
|
369
|
+
export function roomRewriter(play: string, slug: string): (text: string) => string {
|
|
370
|
+
const address = roomAddress(play);
|
|
371
|
+
if (!address) return (text) => text;
|
|
372
|
+
const channelPath = address.channel !== null ? `/api/channels/${address.channel}` : "/api/live";
|
|
373
|
+
return (text) => text
|
|
374
|
+
.split(`${address.origin}${channelPath}/hls/`).join(`/room/${slug}/hls/`)
|
|
375
|
+
.split(`${address.origin}${channelPath}`).join(`/room/${slug}/stream`)
|
|
376
|
+
.split(address.origin).join("")
|
|
377
|
+
.split(`?k=${address.key}&`).join("?").split(`&k=${address.key}`).join("").split(`?k=${address.key}`).join("")
|
|
378
|
+
.split(address.key).join("");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Answers /room/<name>/state, /stream and /hls/<file>, asked of the room's
|
|
383
|
+
* server with its key; false when the path is not one. The page itself is
|
|
384
|
+
* the caller's (it is the built watch page).
|
|
385
|
+
*/
|
|
386
|
+
export async function handleRoomProxy(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomProxyContext): Promise<boolean> {
|
|
387
|
+
const sub = ROOM_SUB.exec(path);
|
|
388
|
+
if (!sub) return false;
|
|
389
|
+
const method = request.method ?? "GET";
|
|
390
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
391
|
+
ctx.json(response, 405, { error: "a room's page can watch, not drive", code: "method_not_allowed" });
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
const slug = sub[1]!;
|
|
395
|
+
const room = roomSlugs(ctx.store.list()).get(slug);
|
|
396
|
+
const address = room ? roomAddress(room.play) : null;
|
|
397
|
+
if (!room || !address) {
|
|
398
|
+
ctx.json(response, 404, { error: "no room by that name", code: "not_found" });
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
const what = sub[2]!;
|
|
402
|
+
if (what === "state") {
|
|
403
|
+
const state = await ctx.probe.state(room.play);
|
|
404
|
+
if (!state) {
|
|
405
|
+
ctx.json(response, 404, { error: "that room is not on the air", code: "unavailable" });
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
const view: RoomView = {
|
|
409
|
+
name: room.name || state.name,
|
|
410
|
+
kind: state.kind,
|
|
411
|
+
live: state.live,
|
|
412
|
+
folder: state.folder === true,
|
|
413
|
+
hls: address.channel !== null,
|
|
414
|
+
title: state.nowPlaying?.title ?? "",
|
|
415
|
+
artist: state.nowPlaying?.artist ?? "",
|
|
416
|
+
album: state.album ?? "",
|
|
417
|
+
};
|
|
418
|
+
response.setHeader("cache-control", "no-store");
|
|
419
|
+
ctx.json(response, 200, view);
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
let upstream: string;
|
|
423
|
+
if (what === "stream") upstream = address.play;
|
|
424
|
+
else {
|
|
425
|
+
if (address.channel === null) {
|
|
426
|
+
ctx.json(response, 404, { error: "the live stream has no HLS", code: "not_found" });
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
upstream = `${address.origin}/api/channels/${address.channel}/${what}?k=${address.key}`;
|
|
430
|
+
}
|
|
431
|
+
if ((await ctx.refuse(upstream).catch(() => "refused")) !== "") {
|
|
432
|
+
ctx.json(response, 502, { error: "that room's server cannot be reached from here", code: "upstream" });
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
const stop = new AbortController();
|
|
436
|
+
response.on("close", () => stop.abort());
|
|
437
|
+
const answer = await (ctx.fetcher ?? fetch)(upstream, {
|
|
438
|
+
method, signal: stop.signal, redirect: "error",
|
|
439
|
+
headers: { ...(request.headers.range ? { range: String(request.headers.range) } : {}), accept: String(request.headers.accept ?? "*/*") },
|
|
440
|
+
}).catch(() => null);
|
|
441
|
+
if (!answer) {
|
|
442
|
+
ctx.json(response, 502, { error: "that room's server did not answer", code: "upstream" });
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
const type = answer.headers.get("content-type") ?? "";
|
|
446
|
+
const text = /json|mpegurl|^text\//i.test(type);
|
|
447
|
+
const headers: Record<string, string> = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
|
|
448
|
+
for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "x-accel-buffering", "retry-after"]) {
|
|
449
|
+
const value = answer.headers.get(name);
|
|
450
|
+
if (value && !(text && name === "content-length")) headers[name] = value;
|
|
451
|
+
}
|
|
452
|
+
if (text) {
|
|
453
|
+
// A playlist (or an error) is small: read whole, the room's address and key taken out.
|
|
454
|
+
const body = roomRewriter(room.play, slug)(await answer.text().catch(() => ""));
|
|
455
|
+
response.writeHead(answer.status, { ...headers, "content-length": String(Buffer.byteLength(body)) });
|
|
456
|
+
response.end(method === "HEAD" ? undefined : body);
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
response.writeHead(answer.status, headers);
|
|
460
|
+
if (!answer.body || method === "HEAD") {
|
|
461
|
+
response.end();
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
for await (const chunk of answer.body as unknown as AsyncIterable<Uint8Array>) {
|
|
466
|
+
if (response.destroyed) break;
|
|
467
|
+
if (!response.write(chunk)) await new Promise((done) => { response.once("drain", done); response.once("close", done); });
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
// The viewer left, or the room's server did.
|
|
471
|
+
}
|
|
472
|
+
response.end();
|
|
473
|
+
return true;
|
|
474
|
+
}
|
package/src/radio-pages.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { Station } from "./stations.ts";
|
|
|
24
24
|
import { albumSlug, artistSlug, stationSlug } from "./radio-slug.ts";
|
|
25
25
|
|
|
26
26
|
/** A station's own paths, as the station link opens them. */
|
|
27
|
-
export const RADIO_SUB = /^\/radio\/([a-z0-9-]{1,60})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
|
|
27
|
+
export const RADIO_SUB = /^\/radio\/([a-z0-9-]{1,60})\/(stream|state|listen\.m3u|events|now|art|lyrics|clock)$/;
|
|
28
28
|
export const RADIO_STATION = /^\/radio\/([a-z0-9-]{1,60})\/?$/;
|
|
29
29
|
export const RADIO_ARTIST = /^\/radio\/artist\/([a-z0-9-]{1,90})\/?$/;
|
|
30
30
|
export const RADIO_ALBUM = /^\/radio\/album\/([a-z0-9-]{1,100})(\/cover\.jpg)?\/?$/;
|
|
@@ -476,8 +476,8 @@ export function renderMissing(what: string, base: string, nav: string): string {
|
|
|
476
476
|
/** What a station page's head says: a station of ours or a peer's. */
|
|
477
477
|
export interface StationFace { name: string; description?: string; video?: boolean }
|
|
478
478
|
|
|
479
|
-
export function stationHead(station: StationFace, slug: string, base: string): string {
|
|
480
|
-
const url = `${base}
|
|
479
|
+
export function stationHead(station: StationFace, slug: string, base: string, path = `/radio/${slug}`): string {
|
|
480
|
+
const url = `${base}${path}`;
|
|
481
481
|
const description = station.description || `${station.name}, live now. Free to listen; tap the artist or album on the air to see more.`;
|
|
482
482
|
return `<title>${escape(station.name)} · live.streaming</title>
|
|
483
483
|
<meta name="description" content="${escape(description)}" />
|
|
@@ -492,9 +492,9 @@ export function stationHead(station: StationFace, slug: string, base: string): s
|
|
|
492
492
|
}
|
|
493
493
|
|
|
494
494
|
/** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
|
|
495
|
-
export function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string): string {
|
|
495
|
+
export function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string, path?: string): string {
|
|
496
496
|
return html
|
|
497
|
-
.replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base))
|
|
497
|
+
.replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base, path))
|
|
498
498
|
.replace(/\s*<meta name="robots" content="noindex"\s*\/?>/, "")
|
|
499
499
|
.replace(/<body>/, `<body>\n${nav}`);
|
|
500
500
|
}
|
package/src/server.ts
CHANGED
|
@@ -92,7 +92,7 @@ import { LyricsFinder } from "./lyrics.ts";
|
|
|
92
92
|
import { LyricsPublisher } from "./lyrics-publish.ts";
|
|
93
93
|
import { aiKey } from "./ai-keys.ts";
|
|
94
94
|
import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML, WATCH_APP_CSP } from "./station-page.ts";
|
|
95
|
-
import { handleRooms, RoomProbe, roomEntries, RoomStore, watchBase } from "./guide-rooms.ts";
|
|
95
|
+
import { handleRoomProxy, handleRooms, isRoomPath, ROOM_PAGE, roomAddress, RoomProbe, roomEntries, roomSlugOf, roomSlugs, RoomStore, watchBase } from "./guide-rooms.ts";
|
|
96
96
|
import { handleVods, VodProbe, vodEntries, VodStore, vodView } from "./guide-vods.ts";
|
|
97
97
|
import { withPitFooter } from "./pit-footer.ts";
|
|
98
98
|
import { handleFollowPage } from "./follow-page.ts";
|
|
@@ -2861,6 +2861,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2861
2861
|
!/^\/watch\/[a-z0-9]{10}$/.test(path) &&
|
|
2862
2862
|
// A live room's page: the room's own key travels in ?play=, checked by its server.
|
|
2863
2863
|
!(path === "/watch" && Boolean(options.stations?.rooms)) &&
|
|
2864
|
+
// A live room under its name here, asked of its server with the key held here (guide-rooms.ts).
|
|
2865
|
+
!(isRoomPath(path) && Boolean(options.stations?.rooms)) &&
|
|
2864
2866
|
// Live rooms on the guide: listed for anybody, published by a member (guide-rooms.ts).
|
|
2865
2867
|
!((path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/")) && Boolean(options.stations?.rooms)) &&
|
|
2866
2868
|
// On demand: a file's page and the guide's list of them (guide-vods.ts); the file's own key travels in its play address.
|
|
@@ -3076,7 +3078,56 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3076
3078
|
// A live room's page: /watch?play=<a nixamp server's channel or live
|
|
3077
3079
|
// stream address>. The built page plays it in @profullstack/player, as a
|
|
3078
3080
|
// film or as radio; nothing about the room passes through here.
|
|
3081
|
+
if (options.stations?.rooms && isRoomPath(path)) {
|
|
3082
|
+
const rooms = options.stations.rooms;
|
|
3083
|
+
if (await handleRoomProxy(request, response, path, { store: rooms.store, probe: rooms.probe, refuse: (link) => refusedDestination(link), json })) return;
|
|
3084
|
+
const asked = ROOM_PAGE.exec(path)![1]!;
|
|
3085
|
+
const slugs = roomSlugs(rooms.store.list());
|
|
3086
|
+
const room = slugs.get(asked);
|
|
3087
|
+
if (!room) {
|
|
3088
|
+
// A room's id finds its name: /room/<id> goes to /room/<name>.
|
|
3089
|
+
const named = roomSlugOf(slugs, asked);
|
|
3090
|
+
if (named) {
|
|
3091
|
+
response.writeHead(301, { location: `/room/${named}`, "cache-control": "public, max-age=300" });
|
|
3092
|
+
response.end();
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
3097
|
+
json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
|
|
3098
|
+
return;
|
|
3099
|
+
}
|
|
3100
|
+
const built = web !== null && isFile(join(web, "watch.html")) ? readFileSync(join(web, "watch.html"), "utf8") : null;
|
|
3101
|
+
if (!built) {
|
|
3102
|
+
json(response, 404, { error: "this server was started without its web pages" });
|
|
3103
|
+
return;
|
|
3104
|
+
}
|
|
3105
|
+
const base = radioBaseFor(request, options.stations.publicBase?.() ?? "");
|
|
3106
|
+
const nav = radioNav(guideHosts().has(requestHost) ? "/" : "/guide", "live");
|
|
3107
|
+
const face = room ? { name: room.name, description: room.note || `${room.name}, live now on live.streaming.`, video: room.kind === "video" } : { name: "No such room" };
|
|
3108
|
+
response.writeHead(room ? 200 : 404, {
|
|
3109
|
+
"content-type": "text/html; charset=utf-8",
|
|
3110
|
+
"cache-control": "no-cache",
|
|
3111
|
+
"content-security-policy": adCsp(WATCH_APP_CSP, guideAds()),
|
|
3112
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
3113
|
+
"x-content-type-options": "nosniff",
|
|
3114
|
+
});
|
|
3115
|
+
response.end(request.method === "HEAD" ? undefined : withPageAd(asRadioPage(built, face, asked, base, nav, `/room/${asked}`), guideAds()));
|
|
3116
|
+
return;
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3079
3119
|
if (options.stations?.rooms && path === "/watch" && (request.method === "GET" || request.method === "HEAD")) {
|
|
3120
|
+
// A room published here plays under its name: the old link keeps working and lands there.
|
|
3121
|
+
const play = roomAddress(url.searchParams.get("play") ?? "");
|
|
3122
|
+
if (play) {
|
|
3123
|
+
const slugs = roomSlugs(options.stations.rooms.store.list());
|
|
3124
|
+
const published = [...slugs].find(([, one]) => one.play === play.play)?.[0];
|
|
3125
|
+
if (published) {
|
|
3126
|
+
response.writeHead(302, { location: `/room/${published}`, "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
3127
|
+
response.end();
|
|
3128
|
+
return;
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3080
3131
|
const built = web !== null && isFile(join(web, "watch.html")) ? withPageAd(readFileSync(join(web, "watch.html"), "utf8"), guideAds()) : null;
|
|
3081
3132
|
if (!built) {
|
|
3082
3133
|
json(response, 404, { error: "this server was started without its web pages" });
|
package/src/station-routes.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* reading English.
|
|
14
14
|
*/
|
|
15
15
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
|
-
import type
|
|
16
|
+
import { chunkStart, type Channels } from "./channels.ts";
|
|
17
17
|
import type { StationRunner } from "./station-runner.ts";
|
|
18
18
|
import { alertsFor, type Limits, type StationUsage } from "./station-metering.ts";
|
|
19
19
|
import { aiFromTags, type TrackFacts } from "./track-facts.ts";
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from "./stations.ts";
|
|
29
29
|
|
|
30
30
|
/** The three things a station's link opens. */
|
|
31
|
-
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
|
|
31
|
+
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art|lyrics|clock)$/;
|
|
32
32
|
|
|
33
33
|
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
34
34
|
export function isStationListenPath(path: string): boolean {
|
|
@@ -105,6 +105,47 @@ function links(ctx: StationContext, station: Station, base = ctx.base): { page:
|
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A listener's clock: where each run of the bytes one connection was sent
|
|
110
|
+
* sits in its channel's byte count. The page asks with its player's
|
|
111
|
+
* currentTime and is told the song and the second it is hearing, exactly,
|
|
112
|
+
* whatever the player has buffered. Keyed by the stream's ?n=.
|
|
113
|
+
*/
|
|
114
|
+
interface ListenerClock { runs: Array<{ local: number; global: number }>; local: number; next: number }
|
|
115
|
+
const clocks = new Map<string, ListenerClock>();
|
|
116
|
+
const MAX_CLOCKS = 5000;
|
|
117
|
+
const MAX_RUNS = 500;
|
|
118
|
+
|
|
119
|
+
/** Records a connection's bytes onto its channel's, through the listener it writes to. */
|
|
120
|
+
export function clockedListener<T extends { write(chunk: Buffer): boolean }>(key: string, out: T): T & { forget(): void } {
|
|
121
|
+
const clock: ListenerClock = { runs: [], local: 0, next: -1 };
|
|
122
|
+
if (clocks.size >= MAX_CLOCKS) clocks.delete(clocks.keys().next().value!);
|
|
123
|
+
clocks.set(key, clock);
|
|
124
|
+
return Object.assign(Object.create(out) as T, {
|
|
125
|
+
write: (chunk: Buffer): boolean => {
|
|
126
|
+
const at = chunkStart(chunk);
|
|
127
|
+
// Silence written in a break's place carries no start: it stands for the break's bytes.
|
|
128
|
+
if (at !== undefined && at !== clock.next) {
|
|
129
|
+
clock.runs.push({ local: clock.local, global: at });
|
|
130
|
+
if (clock.runs.length > MAX_RUNS) clock.runs.shift();
|
|
131
|
+
}
|
|
132
|
+
clock.local += chunk.length;
|
|
133
|
+
clock.next = (at ?? clock.next) + chunk.length;
|
|
134
|
+
return out.write(chunk);
|
|
135
|
+
},
|
|
136
|
+
forget: (): void => { if (clocks.get(key) === clock) clocks.delete(key); },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Which byte of its channel a connection's player is on, `seconds` into what it was sent; null when unknown. */
|
|
141
|
+
export function heardByte(key: string, seconds: number, kbps: number): number | null {
|
|
142
|
+
const clock = clocks.get(key);
|
|
143
|
+
if (!clock || !(seconds >= 0) || kbps <= 0) return null;
|
|
144
|
+
const local = Math.min(clock.local, (seconds * kbps * 1000) / 8);
|
|
145
|
+
const run = clock.runs.findLast((one) => one.local <= local);
|
|
146
|
+
return run ? run.global + (local - run.local) : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
108
149
|
/** The file on the air now, as the channel's own encoder last reported it. */
|
|
109
150
|
function nowPlayingFile(ctx: StationContext, station: Station): string {
|
|
110
151
|
const info = ctx.channels.info(channelOf(station.id));
|
|
@@ -331,6 +372,26 @@ export async function handleStations(
|
|
|
331
372
|
ctx.json(response, 200, { lyrics: { title: lyrics.title, artist: lyrics.artist, text: lyrics.text, source: lyrics.source, language: lyrics.language, ...(lyrics.synced ? { synced: lyrics.synced } : {}) } });
|
|
332
373
|
return true;
|
|
333
374
|
}
|
|
375
|
+
if (what === "clock") {
|
|
376
|
+
// ?n= the stream's nonce, ?ct= the player's currentTime: the song and second being heard.
|
|
377
|
+
const key = `${station.id}:${url.searchParams.get("n") ?? ""}`;
|
|
378
|
+
const byte = heardByte(key, Number(url.searchParams.get("ct")), station.bitrate);
|
|
379
|
+
const place = byte === null ? null : ctx.channels.placeAt(channelOf(station.id), byte);
|
|
380
|
+
if (!place) {
|
|
381
|
+
ctx.json(response, 404, { error: "no clock for that stream", code: "not_found" });
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
const track = place.file ? (place.tags ?? ctx.trackFor(place.file)) : null;
|
|
385
|
+
const duration = place.file && ctx.durationOf ? ctx.durationOf(place.file) : 0;
|
|
386
|
+
ctx.json(response, 200, {
|
|
387
|
+
station: station.id,
|
|
388
|
+
break: place.file === "",
|
|
389
|
+
position: Math.round(place.position * 1000) / 1000,
|
|
390
|
+
duration: Math.round(duration * 10) / 10,
|
|
391
|
+
nowPlaying: track && (track.title || track.artist) ? { title: track.title, artist: track.artist, album: track.album } : null,
|
|
392
|
+
});
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
334
395
|
if (what === "now") {
|
|
335
396
|
const file = ctx.runner.observed(station.id) === "live" || ctx.runner.observed(station.id) === "recovering" ? nowPlayingFile(ctx, station) : "";
|
|
336
397
|
ctx.json(response, 200, { station: station.id, facts: file && ctx.facts ? await ctx.facts(station, file).catch(() => null) : null });
|
|
@@ -398,9 +459,12 @@ export async function handleStations(
|
|
|
398
459
|
pending: (): number => response.writableLength,
|
|
399
460
|
}
|
|
400
461
|
: response;
|
|
462
|
+
const nonce = /^[A-Za-z0-9_-]{1,40}$/.test(url.searchParams.get("n") ?? "") ? url.searchParams.get("n")! : "";
|
|
463
|
+
const clocked = nonce && !station.video ? clockedListener(`${station.id}:${nonce}`, metered) : null;
|
|
464
|
+
const out = clocked ?? metered;
|
|
401
465
|
const detach = spared
|
|
402
|
-
? ctx.channels.listenWithoutBreaks(channelId,
|
|
403
|
-
: ctx.channels.listen(channelId,
|
|
466
|
+
? ctx.channels.listenWithoutBreaks(channelId, out, station.bitrate, (quiet) => quietDuringBreaks(quiet, station.bitrate, () => true))
|
|
467
|
+
: ctx.channels.listen(channelId, out);
|
|
404
468
|
if (detach === null) {
|
|
405
469
|
response.end();
|
|
406
470
|
return true;
|
|
@@ -414,6 +478,7 @@ export async function handleStations(
|
|
|
414
478
|
detach();
|
|
415
479
|
untrack();
|
|
416
480
|
left?.();
|
|
481
|
+
clocked?.forget();
|
|
417
482
|
};
|
|
418
483
|
request.on("close", leave);
|
|
419
484
|
response.on("close", leave);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./main-
|
|
1
|
+
import{t as e}from"./main-cry6C8KD.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./main-
|
|
1
|
+
import{t as e}from"./main-cry6C8KD.js";var t={...e,"No parties live right now.":`Právě neprobíhají žádné párty.`,"Could not refresh parties.":`Párty se nepodařilo aktualizovat.`,"Pick a server in the directory, or paste its link here.":`Vyberte server v seznamu nebo sem vložte jeho odkaz.`,"Interface language":`Jazyk rozhraní`,Language:`Jazyk`,"Translation language":`Jazyk překladu`,"Original (auto-detect)":`Původní (automatické rozpoznání)`,Play:`Přehrát`,Pause:`Pozastavit`,Stop:`Zastavit`,Next:`Další`,Previous:`Předchozí`,Select:`Vybrat`,"Next/Prev":`Další/Předchozí`,Quit:`Ukončit`,"Now Playing":`Právě hraje`,"Spectrum Analyser":`Spektrální analyzátor`,Playlist:`Seznam skladeb`,Files:`Soubory`,"Open files":`Otevřít soubory`,"Open folder":`Otevřít složku`,"Add to library":`Přidat do knihovny`,"Replace playlist":`Nahradit seznam`,Filter:`Filtrovat`,Search:`Hledat`,"Nothing loaded.":`Nic není načteno.`,"Empty.":`Prázdné.`,"Loading…":`Načítání…`,"Connecting…":`Připojování…`,Connected:`Připojeno`,"Not connected":`Nepřipojeno`,"No source":`Žádný zdroj`,Playing:`Přehrávání`,Stopped:`Zastaveno`,Volume:`Hlasitost`,Seek:`Pozice přehrávání`,"Full screen":`Celá obrazovka`,Download:`Stáhnout`,Share:`Sdílet`,Copy:`Kopírovat`,"Copy link":`Kopírovat odkaz`,Copied:`Zkopírováno`,Save:`Uložit`,Cancel:`Zrušit`,Close:`Zavřít`,Remove:`Odebrat`,Delete:`Smazat`,Edit:`Upravit`,Add:`Přidat`,Send:`Odeslat`,Retry:`Zkusit znovu`,Back:`Zpět`,"Next page":`Další stránka`,"Previous page":`Předchozí stránka`,"Show more":`Zobrazit více`,Panels:`Panely`,"Reset layout":`Obnovit rozložení`,Show:`Zobrazit`,Hide:`Skrýt`,Collapse:`Sbalit`,Expand:`Rozbalit`,"Move up":`Nahoru`,"Move down":`Dolů`,Install:`Nainstalovat`,"TV mode":`Režim TV`,On:`Zapnuto`,Off:`Vypnuto`,Account:`Účet`,"Sign in":`Přihlásit se`,"Sign out":`Odhlásit se`,"Create account":`Vytvořit účet`,Email:`E-mail`,Password:`Heslo`,"Forgot password?":`Zapomenuté heslo?`,"Welcome back":`Vítejte zpět`,Profile:`Profil`,Handle:`Uživatelské jméno`,"Your servers":`Vaše servery`,Server:`Server`,Connect:`Připojit`,Disconnect:`Odpojit`,Directory:`Adresář`,Favourites:`Oblíbené`,Follow:`Sledovat`,Unfollow:`Přestat sledovat`,Notifications:`Oznámení`,"Phone number":`Telefonní číslo`,"Save number":`Uložit číslo`,Transcript:`Přepis`,Captions:`Titulky`,"Translate audio":`Překládat zvuk`,"Audio language":`Jazyk zvuku`,"Audio options":`Možnosti zvuku`,"Keep background sound":`Zachovat zvuk na pozadí`,"Background level":`Hlasitost pozadí`,"Speaker voices":`Hlasy mluvčích`,"Any voice":`Libovolný hlas`,"Male voice":`Mužský hlas`,"Female voice":`Ženský hlas`,"Original audio.":`Původní zvuk.`,"Buy translated audio":`Koupit překlad zvuku`,"Audio credit":`Zvukový kredit`,"Buy pass":`Koupit přístup`,Pass:`Přístup`,"Pay with":`Zaplatit pomocí`,"Check payment":`Ověřit platbu`,"Open checkout":`Přejít k platbě`,"Free sessions":`Bezplatné relace`,"Usage rates and expiry":`Ceny a platnost`,Catalogs:`Katalogy`,"Add catalog":`Přidat katalog`,"Catalog name":`Název katalogu`,Link:`Odkaz`,"Play link":`Přehrát odkaz`,"Go live":`Vysílat živě`,Publish:`Zveřejnit`,Listen:`Poslouchat`,"Live now":`Právě živě`,Upcoming:`Připravované`,Parties:`Společná sledování`,"Join party":`Připojit se ke sledování`,"Invite code":`Kód pozvánky`,Invite:`Pozvat`,Chat:`Chat`,"Chat message":`Zpráva chatu`,People:`Lidé`,Speaker:`Mluvčí`,Speakers:`Mluvčí`,Listener:`Posluchač`,Moderator:`Moderátor`,"Raise hand":`Zvednout ruku`,"Hand raises":`Zvednuté ruce`,Questions:`Otázky`,Resources:`Materiály`,Replay:`Záznam`,Schedule:`Rozvrh`,Event:`Událost`,Host:`Pořadatel`,About:`O události`,Stage:`Pódium`,Title:`Název`,Description:`Popis`,Topic:`Téma`,Public:`Veřejné`,"Anyone with the link":`Kdokoli s odkazem`,"Invited people":`Pozvané osoby`,"Who can join":`Kdo se může připojit`,"Date and time":`Datum a čas`,"Expected length":`Očekávaná délka`,Repeat:`Opakovat`,"Does not repeat":`Neopakuje se`,"Every day":`Každý den`,"Every week":`Každý týden`,Homepage:`Webové stránky`,"Host name":`Jméno pořadatele`,"Profile photo URL":`URL profilové fotky`,"Broadcast link":`Odkaz na vysílání`,"Edit event":`Upravit událost`,"Save changes":`Uložit změny`,"Create live":`Vytvořit vysílání`,"Schedule class":`Naplánovat lekci`,"Start class":`Zahájit lekci`,"End event":`Ukončit událost`,"End session":`Ukončit relaci`,"Session ended":`Relace ukončena`,"Event ended":`Událost ukončena`,Cancelled:`Zrušeno`,"Replay available":`Záznam je k dispozici`,Join:`Připojit se`,"Create invite":`Vytvořit pozvánku`,"Invite as":`Pozvat jako`,"Watch now":`Sledovat nyní`,"Start a room":`Vytvořit místnost`,"Recent replays":`Nedávné záznamy`,"Your account":`Váš účet`,"Write with AI":`Psát pomocí AI`,"Suggested title":`Navrhovaný název`,"Suggested description":`Navrhovaný popis`,"Suggested topic":`Navrhované téma`,"Use these details":`Použít tyto údaje`,"Learn something live.":`Učte se naživo.`,"Screen share":`Sdílet obrazovku`,"Upload and broadcast":`Nahrát a vysílat`,Help:`Nápověda`,"Reload Player":`Znovu načíst přehrávač`,"Copy Bundled CLI Path":`Kopírovat cestu k přibalenému CLI`,"Developer tools":`Vývojářské nástroje`,Undo:`Zpět`,Redo:`Znovu`,Cut:`Vyjmout`,Paste:`Vložit`,"Select all":`Vybrat vše`,Status:`Stav`,"Activity log":`Protokol aktivit`,Admin:`Správa`,"Playback controls":`Ovládání přehrávání`,"Tracks and folders":`Skladby a složky`,"Original-language captions.":`Titulky v původním jazyce.`,"Skip to playback controls":`Přejít k ovládání přehrávání`,"Skip to playlist":`Přejít k seznamu skladeb`,"Skip to transcript and translated audio":`Přejít k přepisu a přeloženému zvuku`};export{t as default};
|