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.
Files changed (44) hide show
  1. package/dist/channels.d.ts +20 -0
  2. package/dist/channels.js +38 -0
  3. package/dist/guide-rooms.d.ts +42 -0
  4. package/dist/guide-rooms.js +157 -7
  5. package/dist/radio-pages.d.ts +2 -2
  6. package/dist/radio-pages.js +5 -5
  7. package/dist/server.js +52 -1
  8. package/dist/station-routes.d.ts +9 -1
  9. package/dist/station-routes.js +63 -3
  10. package/package.json +1 -1
  11. package/src/channels.ts +43 -0
  12. package/src/guide-rooms.ts +182 -8
  13. package/src/radio-pages.ts +5 -5
  14. package/src/server.ts +52 -1
  15. package/src/station-routes.ts +69 -4
  16. package/web/dist/assets/{ar-HteUbQ_m.js → ar-CBn9fObz.js} +1 -1
  17. package/web/dist/assets/{cs-CjN_4DAJ.js → cs-BvEScz92.js} +1 -1
  18. package/web/dist/assets/{da-CzsSXVfk.js → da-uKElVtgi.js} +1 -1
  19. package/web/dist/assets/{de-D7zVt1UT.js → de-DXEoI1GG.js} +1 -1
  20. package/web/dist/assets/{es-D8duNQiq.js → es-Q-QEZFh2.js} +1 -1
  21. package/web/dist/assets/{fi-CSKCYE5E.js → fi-ClJkdfXs.js} +1 -1
  22. package/web/dist/assets/{fr-DnjuIwaN.js → fr-DWmNoP1h.js} +1 -1
  23. package/web/dist/assets/{hi-ByZe82O5.js → hi-DGQOrveg.js} +1 -1
  24. package/web/dist/assets/{hu-B3iwlAuW.js → hu-DGcKM8DB.js} +1 -1
  25. package/web/dist/assets/{id-B58CS7rH.js → id-D18Cz5RW.js} +1 -1
  26. package/web/dist/assets/{it-CaVLbop_.js → it-WQxuRjlI.js} +1 -1
  27. package/web/dist/assets/{main-yTRNIxIl.js → main-cry6C8KD.js} +2 -2
  28. package/web/dist/assets/{nl-Dk0ss8Tf.js → nl-BvOA9QBr.js} +1 -1
  29. package/web/dist/assets/{ru-CGuA41ji.js → ru-Cx4tgP1k.js} +1 -1
  30. package/web/dist/assets/{station-CSnjPeod.css → station-6b_smJHe.css} +1 -1
  31. package/web/dist/assets/station-BzLUgyQ0.js +3 -0
  32. package/web/dist/assets/{sv-B309Td6Z.js → sv-f0zj1AdU.js} +1 -1
  33. package/web/dist/assets/{uk-CCCKvR-U.js → uk-DpGYtFFI.js} +1 -1
  34. package/web/dist/assets/{vi-DfVTbK_t.js → vi-Cxf7VqJQ.js} +1 -1
  35. package/web/dist/assets/watch-Bygo4bPY.js +1 -0
  36. package/web/dist/assets/{zh-BU9_CD0f.js → zh-DyuYbygO.js} +1 -1
  37. package/web/dist/index.html +1 -1
  38. package/web/dist/station.html +2 -2
  39. package/web/dist/sw.js +24 -24
  40. package/web/dist/vod.html +2 -2
  41. package/web/dist/watch.html +2 -2
  42. package/web/dist/assets/station-D6sd4DqP.js +0 -3
  43. package/web/dist/assets/watch-Dxsz5gHd.js +0 -1
  44. /package/web/dist/assets/{vod-Br9Ooz6-.js → vod-Bbg_gWbl.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.57.1",
3
+ "version": "0.58.1",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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);
@@ -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, and its card opens /watch?play=<that address>, which plays it as
9
- * a film or as radio, whichever it carries.
8
+ * stations; its card opens /room/<name>, which plays it as a film or as
9
+ * radio, whichever it carries.
10
10
  *
11
- * Nothing here plays anything: the room's own server does, and this only asks
12
- * it, now and then, whether the room is on the air and what is on it.
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
- codecs?: { tags?: { title?: string; artist?: string } };
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: watchPage(base, room.play),
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: watchPage(ctx.base, room.play) });
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
+ }
@@ -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}/radio/${slug}`;
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" });
@@ -13,7 +13,7 @@
13
13
  * reading English.
14
14
  */
15
15
  import type { IncomingMessage, ServerResponse } from "node:http";
16
- import type { Channels } from "./channels.ts";
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, metered, station.bitrate, (out) => quietDuringBreaks(out, station.bitrate, () => true))
403
- : ctx.channels.listen(channelId, metered);
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-yTRNIxIl.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
+ 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-yTRNIxIl.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};
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};