nixamp 0.44.0 → 0.45.0

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 (51) hide show
  1. package/dist/guide-rooms.d.ts +90 -0
  2. package/dist/guide-rooms.js +256 -0
  3. package/dist/library.d.ts +2 -0
  4. package/dist/pit-footer.d.ts +3 -0
  5. package/dist/pit-footer.js +40 -0
  6. package/dist/server.d.ts +6 -0
  7. package/dist/server.js +72 -3
  8. package/dist/station-guide.d.ts +2 -2
  9. package/dist/station-guide.js +8 -4
  10. package/dist/station-page.d.ts +5 -0
  11. package/dist/station-page.js +15 -0
  12. package/dist/station-routes.d.ts +2 -0
  13. package/dist/station-routes.js +3 -1
  14. package/package.json +1 -1
  15. package/src/guide-rooms.ts +296 -0
  16. package/src/library.ts +2 -0
  17. package/src/pit-footer.ts +40 -0
  18. package/src/server.ts +76 -3
  19. package/src/station-guide.ts +10 -6
  20. package/src/station-page.ts +16 -0
  21. package/src/station-routes.ts +5 -1
  22. package/web/dist/assets/{ar-CFKc4sST.js → ar-CV8jyOug.js} +1 -1
  23. package/web/dist/assets/{cs-hYUvrLOA.js → cs-_qnbzDla.js} +1 -1
  24. package/web/dist/assets/{da-CDVRP4eF.js → da-B5FYL3Hl.js} +1 -1
  25. package/web/dist/assets/{de-Cnsv6ovL.js → de-CBwqcL_O.js} +1 -1
  26. package/web/dist/assets/{es-BCZf7hTI.js → es-Cqpz6_wS.js} +1 -1
  27. package/web/dist/assets/{fi-CJFGk4B4.js → fi-BCckNeQZ.js} +1 -1
  28. package/web/dist/assets/{fr-L6t6E_D0.js → fr-BtTpQJDS.js} +1 -1
  29. package/web/dist/assets/{hi-CYNtSMhg.js → hi-kHrHp-LW.js} +1 -1
  30. package/web/dist/assets/{hu-Cy2Vq3jY.js → hu-uG0HRO6c.js} +1 -1
  31. package/web/dist/assets/{id-DQOdpqH0.js → id-hoEx4_vN.js} +1 -1
  32. package/web/dist/assets/{it-BAB9lSHI.js → it-Bx4j5Tgv.js} +1 -1
  33. package/web/dist/assets/main-B__9rtDL.js +22 -0
  34. package/web/dist/assets/{nl-CC8ycc0R.js → nl-BZCUFXO1.js} +1 -1
  35. package/web/dist/assets/{radio-Cvjck2GD.js → radio-BU-XH1pD.js} +1 -1
  36. package/web/dist/assets/{ru-acMGqMTM.js → ru-CnhmLVES.js} +1 -1
  37. package/web/dist/assets/{station-B9ORrH8G.js → station-D3ICZ3fF.js} +1 -1
  38. package/web/dist/assets/styles-dKtpEefI.css +1 -0
  39. package/web/dist/assets/{sv-D4aGnzyX.js → sv-DgLuYxwC.js} +1 -1
  40. package/web/dist/assets/{uk-B7NCqrVY.js → uk-DSdKgI41.js} +1 -1
  41. package/web/dist/assets/{vi-C0pXT2cL.js → vi-BR1oDS1f.js} +1 -1
  42. package/web/dist/assets/watch-DHAXWXqJ.js +1 -0
  43. package/web/dist/assets/{zh-kT9LPD9I.js → zh-Dcq7yWXP.js} +1 -1
  44. package/web/dist/index.html +40 -5
  45. package/web/dist/radio.html +3 -3
  46. package/web/dist/station.html +3 -3
  47. package/web/dist/sw.js +25 -23
  48. package/web/dist/watch.html +30 -0
  49. package/web/dist/assets/main-CnCKUBjG.js +0 -22
  50. package/web/dist/assets/styles-qC62Gona.css +0 -1
  51. /package/web/dist/assets/{styles-D70tSrIe.js → styles-DmL68lWt.js} +0 -0
@@ -100,7 +100,9 @@ export async function handleStations(request, response, path, url, ctx) {
100
100
  const shown = new Set(own.map((one) => one.page));
101
101
  const others = url.searchParams.has("local") || !ctx.peers ? [] : (await ctx.peers())
102
102
  .filter((one) => !shown.has(one.page));
103
- ctx.json(response, 200, { stations: [...own, ...others] });
103
+ // Rooms are this guide's own; a peer asking for its stations does not list them twice.
104
+ const rooms = url.searchParams.has("local") || !ctx.rooms ? [] : await ctx.rooms();
105
+ ctx.json(response, 200, { stations: [...rooms, ...own, ...others] });
104
106
  return true;
105
107
  }
106
108
  const listen = LISTEN.exec(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
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",
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Live rooms on the station guide.
3
+ *
4
+ * A room is a channel, or the live stream, of any nixamp server: the address
5
+ * a Share or a "?play=" link carries, `https://host:port/api/channels/<id>?k=KEY`
6
+ * or `https://host:port/api/live?k=KEY`. A nixamp.com member publishes one with
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.
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.
13
+ */
14
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { randomBytes } from "node:crypto";
17
+ import type { IncomingMessage, ServerResponse } from "node:http";
18
+ import { readConfig } from "./library.ts";
19
+ import type { GuideEntry } from "./station-guide.ts";
20
+
21
+ /** Why a room may be broadcast, the same five reasons a station gives. */
22
+ export const ROOM_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"] as const;
23
+ export type RoomBasis = typeof ROOM_BASES[number];
24
+
25
+ export interface GuideRoom {
26
+ id: string;
27
+ /** The room's stream address, with its listen key. */
28
+ play: string;
29
+ name: string;
30
+ kind: "audio" | "video";
31
+ basis: RoomBasis;
32
+ note: string;
33
+ /** The nixamp.com account that published it ("" for this server's owner). */
34
+ by: string;
35
+ publishedAt: number;
36
+ }
37
+
38
+ /** Rooms one member may have on the guide at once, and rooms on it in all. */
39
+ export const ROOMS_PER_MEMBER = 5;
40
+ export const ROOMS_MAX = 200;
41
+ const STATE_TTL_MS = 15_000;
42
+ const PROBE_TIMEOUT_MS = 4_000;
43
+
44
+ /**
45
+ * A room's address taken apart, or null when it is not one: https (http only
46
+ * for a loopback address, for tests), a channel or the live stream, a key,
47
+ * and nothing else in the query.
48
+ */
49
+ export function roomAddress(input: string): { origin: string; channel: string | null; key: string; play: string } | null {
50
+ let url: URL;
51
+ try {
52
+ url = new URL(input.trim());
53
+ } catch {
54
+ return null;
55
+ }
56
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost";
57
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null;
58
+ if (url.username || url.password || url.hash) return null;
59
+ const channel = /^\/api\/channels\/([A-Za-z0-9._~-]{1,120})\/?$/.exec(url.pathname);
60
+ const live = /^\/api\/live\/?$/.test(url.pathname);
61
+ if (!channel && !live) return null;
62
+ const key = url.searchParams.get("k") ?? "";
63
+ if (!/^[A-Za-z0-9_-]{8,128}$/.test(key) || [...url.searchParams.keys()].some((name) => name !== "k")) return null;
64
+ const path = channel ? `/api/channels/${channel[1]}` : "/api/live";
65
+ return { origin: url.origin, channel: channel ? channel[1]! : null, key, play: `${url.origin}${path}?k=${key}` };
66
+ }
67
+
68
+ /** Where the guide links a room's page: config `watchBase`, NIXAMP_WATCH_BASE, else this server. */
69
+ export function watchBase(fallback: string, env: Record<string, string | undefined> = process.env): string {
70
+ const saved = (() => { try { return readConfig().watchBase ?? ""; } catch { return ""; } })();
71
+ const chosen = (env["NIXAMP_WATCH_BASE"] || saved || fallback).trim().replace(/\/+$/, "");
72
+ return /^https?:\/\/[^\s"<>]+$/.test(chosen) ? chosen : fallback;
73
+ }
74
+
75
+ export const watchPage = (base: string, play: string): string => `${base}/watch?play=${encodeURIComponent(play)}`;
76
+
77
+ export class RoomStore {
78
+ private rooms: GuideRoom[] = [];
79
+ constructor(private readonly file: string) {
80
+ try {
81
+ if (existsSync(file)) {
82
+ const saved = JSON.parse(readFileSync(file, "utf8")) as { rooms?: GuideRoom[] };
83
+ this.rooms = (saved.rooms ?? []).filter((one) => roomAddress(one.play) !== null);
84
+ }
85
+ } catch {
86
+ // Unreadable is empty; the next write replaces it.
87
+ }
88
+ }
89
+
90
+ static fileFor(stateDir: string, port: number): string {
91
+ return join(stateDir, `guide-rooms-${port}.json`);
92
+ }
93
+
94
+ list(): GuideRoom[] {
95
+ return [...this.rooms];
96
+ }
97
+
98
+ get(id: string): GuideRoom | undefined {
99
+ return this.rooms.find((one) => one.id === id);
100
+ }
101
+
102
+ /** Publishes a room, or refreshes the one already published at that address. */
103
+ put(room: Omit<GuideRoom, "id" | "publishedAt">): GuideRoom {
104
+ const existing = this.rooms.find((one) => one.play === room.play);
105
+ const saved: GuideRoom = { ...room, id: existing?.id ?? randomBytes(6).toString("base64url"), publishedAt: Date.now() };
106
+ this.rooms = [...this.rooms.filter((one) => one.play !== room.play), saved];
107
+ this.save();
108
+ return saved;
109
+ }
110
+
111
+ remove(id: string): boolean {
112
+ const before = this.rooms.length;
113
+ this.rooms = this.rooms.filter((one) => one.id !== id);
114
+ if (this.rooms.length === before) return false;
115
+ this.save();
116
+ return true;
117
+ }
118
+
119
+ private save(): void {
120
+ writeFileSync(`${this.file}.part`, `${JSON.stringify({ version: 1, rooms: this.rooms }, null, 2)}\n`);
121
+ renameSync(`${this.file}.part`, this.file);
122
+ }
123
+ }
124
+
125
+ export interface RoomState {
126
+ /** Whether the room's server says it is on the air. */
127
+ live: boolean;
128
+ name: string;
129
+ kind: "audio" | "video";
130
+ nowPlaying: { title: string; artist: string } | null;
131
+ }
132
+
133
+ interface ChannelRow {
134
+ id: string;
135
+ name?: string;
136
+ kind?: string;
137
+ live?: boolean;
138
+ codecs?: { tags?: { title?: string; artist?: string } };
139
+ }
140
+
141
+ /** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
142
+ export class RoomProbe {
143
+ private cache = new Map<string, { at: number; state: RoomState | null }>();
144
+ /**
145
+ * `refuse` names why an address may not be fetched from this machine (a
146
+ * private or loopback network), or "": a member chooses the address, and
147
+ * this server must not become their way into its own network.
148
+ */
149
+ constructor(
150
+ private readonly fetcher: typeof fetch = fetch,
151
+ private readonly refuse: (link: string) => Promise<string> = async () => "",
152
+ ) {}
153
+
154
+ async state(play: string, fresh = false): Promise<RoomState | null> {
155
+ const cached = this.cache.get(play);
156
+ if (!fresh && cached && Date.now() - cached.at < STATE_TTL_MS) return cached.state;
157
+ const state = await this.ask(play).catch(() => null);
158
+ this.cache.set(play, { at: Date.now(), state });
159
+ if (this.cache.size > 500) this.cache.delete(this.cache.keys().next().value!);
160
+ return state;
161
+ }
162
+
163
+ private async ask(play: string): Promise<RoomState | null> {
164
+ const address = roomAddress(play);
165
+ if (!address || (await this.refuse(address.play)) !== "") return null;
166
+ if (address.channel !== null) {
167
+ const answer = await this.fetcher(`${address.origin}/api/channels?k=${address.key}`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), redirect: "error" });
168
+ if (!answer.ok) return null;
169
+ const body = (await answer.json()) as { channels?: ChannelRow[] };
170
+ const row = (body.channels ?? []).find((one) => one.id === address.channel);
171
+ if (!row) return null;
172
+ const tags = row.codecs?.tags;
173
+ return {
174
+ live: row.live !== false,
175
+ name: String(row.name ?? address.channel).replace(/\/+$/, "").slice(0, 120),
176
+ kind: row.kind === "video" ? "video" : "audio",
177
+ nowPlaying: tags && (tags.title || tags.artist) ? { title: String(tags.title ?? ""), artist: String(tags.artist ?? "") } : null,
178
+ };
179
+ }
180
+ // The live stream: on the air when it answers with media.
181
+ const controller = new AbortController();
182
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
183
+ try {
184
+ const answer = await this.fetcher(address.play, { signal: controller.signal, redirect: "error" });
185
+ const type = answer.headers.get("content-type") ?? "";
186
+ controller.abort();
187
+ if (!answer.ok) return null;
188
+ return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null };
189
+ } finally {
190
+ clearTimeout(timer);
191
+ }
192
+ }
193
+ }
194
+
195
+ /** The rooms as guide cards: the page to open, on the air or not, and what is on. */
196
+ export async function roomEntries(store: RoomStore, probe: RoomProbe, base: string): Promise<GuideEntry[]> {
197
+ return Promise.all(store.list().map(async (room) => {
198
+ const state = await probe.state(room.play);
199
+ return {
200
+ id: room.id,
201
+ name: room.name,
202
+ kind: state?.kind ?? room.kind,
203
+ group: "rooms" as const,
204
+ state: state?.live ? "live" : "offline",
205
+ page: watchPage(base, room.play),
206
+ nowPlaying: state?.nowPlaying ?? null,
207
+ };
208
+ }));
209
+ }
210
+
211
+ export interface RoomContext {
212
+ store: RoomStore;
213
+ probe: RoomProbe;
214
+ /** The page base for a room's card. */
215
+ base: string;
216
+ /** Whether the request holds this server's control key (or is its signed-in owner). */
217
+ canControl: boolean;
218
+ /** The nixamp.com account behind the request's session, or "". */
219
+ member: () => Promise<string>;
220
+ json: (response: ServerResponse, status: number, body: unknown) => void;
221
+ readBody: (request: IncomingMessage) => Promise<unknown>;
222
+ /** Refused by the caller's rate limit, or null. */
223
+ throttle?: () => { retryAfter: number } | null;
224
+ }
225
+
226
+ /** Answers /api/v1/guide/rooms; false when the path is not one. */
227
+ export async function handleRooms(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomContext): Promise<boolean> {
228
+ if (path !== "/api/v1/guide/rooms" && !path.startsWith("/api/v1/guide/rooms/")) return false;
229
+ const method = request.method ?? "GET";
230
+ if (path === "/api/v1/guide/rooms" && (method === "GET" || method === "HEAD")) {
231
+ ctx.json(response, 200, { rooms: await roomEntries(ctx.store, ctx.probe, ctx.base) });
232
+ return true;
233
+ }
234
+ const by = ctx.canControl ? "" : await ctx.member();
235
+ if (!ctx.canControl && by === "") {
236
+ ctx.json(response, 401, { error: "sign in to nixamp.com to publish a live room", code: "sign_in" });
237
+ return true;
238
+ }
239
+ const refused = ctx.throttle?.();
240
+ if (refused) {
241
+ response.setHeader("retry-after", String(refused.retryAfter));
242
+ ctx.json(response, 429, { error: "too many changes; wait a moment", code: "throttled" });
243
+ return true;
244
+ }
245
+ if (path === "/api/v1/guide/rooms" && method === "POST") {
246
+ const body = (await ctx.readBody(request).catch(() => null)) as { play?: unknown; basis?: unknown; note?: unknown; title?: unknown; agree?: unknown } | null;
247
+ const address = roomAddress(typeof body?.play === "string" ? body.play : "");
248
+ if (!address) {
249
+ ctx.json(response, 400, { error: "play must be a nixamp room's stream address: https://host/api/channels/<id>?k=KEY", code: "bad_address" });
250
+ return true;
251
+ }
252
+ const basis = ROOM_BASES.find((one) => one === body?.basis);
253
+ if (!basis || body?.agree !== true) {
254
+ ctx.json(response, 400, { error: "say why it may be broadcast (owned, permission, licensed, creative-commons or public-domain) and agree", code: "basis_required" });
255
+ return true;
256
+ }
257
+ const state = await ctx.probe.state(address.play, true);
258
+ if (!state) {
259
+ ctx.json(response, 404, { error: "that room is not on its server, or the key does not open it", code: "not_found" });
260
+ return true;
261
+ }
262
+ const all = ctx.store.list();
263
+ const mine = all.filter((one) => one.by === by && one.play !== address.play);
264
+ if (by !== "" && mine.length >= ROOMS_PER_MEMBER) {
265
+ ctx.json(response, 409, { error: `a member may have ${ROOMS_PER_MEMBER} rooms on the guide; take one off first`, code: "too_many" });
266
+ return true;
267
+ }
268
+ if (!all.some((one) => one.play === address.play) && all.length >= ROOMS_MAX) {
269
+ ctx.json(response, 409, { error: "the guide is full", code: "full" });
270
+ return true;
271
+ }
272
+ const taken = all.find((one) => one.play === address.play);
273
+ if (taken && taken.by !== by && !ctx.canControl) {
274
+ ctx.json(response, 409, { error: "somebody else published that room", code: "taken" });
275
+ return true;
276
+ }
277
+ const title = typeof body?.title === "string" ? body.title.trim().slice(0, 120) : "";
278
+ const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
279
+ const room = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, basis, note, by: taken?.by ?? by });
280
+ ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: watchPage(ctx.base, room.play) });
281
+ return true;
282
+ }
283
+ const one = /^\/api\/v1\/guide\/rooms\/([A-Za-z0-9_-]{1,32})$/.exec(path);
284
+ if (one && method === "DELETE") {
285
+ const room = ctx.store.get(one[1]!);
286
+ if (!room || (!ctx.canControl && room.by !== by)) {
287
+ ctx.json(response, 404, { error: "no such room of yours on the guide", code: "not_found" });
288
+ return true;
289
+ }
290
+ ctx.store.remove(room.id);
291
+ ctx.json(response, 200, { removed: room.id });
292
+ return true;
293
+ }
294
+ ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
295
+ return true;
296
+ }
package/src/library.ts CHANGED
@@ -31,6 +31,8 @@ export interface Config {
31
31
  guideHosts?: string[];
32
32
  /** Other servers (https base addresses) whose public, listed stations this guide shows too. */
33
33
  guidePeers?: string[];
34
+ /** Where the guide links a published live room's page (https://live.streaming.pit.world); this server's own address when unset. */
35
+ watchBase?: string;
34
36
  /** crawlproof on the guide: tracker project id (site), ad slot and format (`nixamp station guide-ads`). */
35
37
  guideAds?: { site?: string; slot?: string; format?: "banner_300x250" | "text_link" };
36
38
  /** Station limits kept across restarts (station-metering.ts). */
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The pit.world global footer (site name, search, ring, publish), fetched on
3
+ * the server and appended as the last thing in <body> of the nixamp.com pages.
4
+ *
5
+ * A module-level cache: the last good copy is served, and one older than an
6
+ * hour is refreshed in the background (3s timeout). A failed refresh keeps the
7
+ * old copy; with no copy at all the page goes out unchanged, so pit.world being
8
+ * down never breaks a page.
9
+ */
10
+ const PIT_FOOTER_URL = "https://pit.world/snippets/footer.html?site=nixamp.com";
11
+ const MAX_AGE_MS = 60 * 60 * 1000;
12
+
13
+ let cached = "";
14
+ let fetchedAt = 0;
15
+ let inflight: Promise<void> | null = null;
16
+
17
+ export function refreshPitFooter(): Promise<void> {
18
+ inflight ??= fetch(PIT_FOOTER_URL, { signal: AbortSignal.timeout(3000) })
19
+ .then(async (res) => {
20
+ const text = res.ok ? await res.text() : "";
21
+ if (!text.includes("data-pit-footer")) throw new Error(`pit footer: HTTP ${res.status}`);
22
+ cached = text;
23
+ fetchedAt = Date.now();
24
+ })
25
+ .catch(() => {
26
+ fetchedAt = Date.now() - MAX_AGE_MS + 60_000; // try again in a minute
27
+ })
28
+ .finally(() => {
29
+ inflight = null;
30
+ });
31
+ return inflight;
32
+ }
33
+
34
+ /** The page with the footer before its last </body>, or the page unchanged. */
35
+ export function withPitFooter(page: string): string {
36
+ if (Date.now() - fetchedAt > MAX_AGE_MS) void refreshPitFooter();
37
+ if (!cached || page.includes("data-pit-footer")) return page;
38
+ const at = page.lastIndexOf("</body>");
39
+ return at < 0 ? page : page.slice(0, at) + cached + page.slice(at);
40
+ }
package/src/server.ts CHANGED
@@ -85,7 +85,9 @@ import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, SupportSubscriptions,
85
85
  import { LyricsFinder } from "./lyrics.ts";
86
86
  import { LyricsPublisher } from "./lyrics-publish.ts";
87
87
  import { aiKey } from "./ai-keys.ts";
88
- import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
88
+ import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML, WATCH_APP_CSP } from "./station-page.ts";
89
+ import { handleRooms, RoomProbe, roomEntries, RoomStore, watchBase } from "./guide-rooms.ts";
90
+ import { withPitFooter } from "./pit-footer.ts";
89
91
  import { guideAds, guideCsp, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide, type GuideEntry } from "./station-guide.ts";
90
92
  import { StationRunner } from "./station-runner.ts";
91
93
  import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
@@ -1755,6 +1757,8 @@ export interface HandlerOptions {
1755
1757
  trustedProxies?: () => Set<string>;
1756
1758
  /** Other servers' public stations, for this guide to list beside its own. */
1757
1759
  peers?: PeerGuides;
1760
+ /** Live rooms members published to this guide, and how to ask a room's server what is on it. */
1761
+ rooms?: { store: RoomStore; probe: RoomProbe };
1758
1762
  };
1759
1763
  web: string | null;
1760
1764
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
@@ -2710,6 +2714,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2710
2714
  !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2711
2715
  // A station's short link, which sends the browser to wherever it plays.
2712
2716
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2717
+ // A live room's page: the room's own key travels in ?play=, checked by its server.
2718
+ !(path === "/watch" && Boolean(options.stations?.rooms)) &&
2719
+ // Live rooms on the guide: listed for anybody, published by a member (guide-rooms.ts).
2720
+ !((path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/")) && Boolean(options.stations?.rooms)) &&
2713
2721
  !STATION_PAGE.test(path) &&
2714
2722
  !isPublicAsset &&
2715
2723
  !isGuidePage &&
@@ -2877,8 +2885,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2877
2885
  });
2878
2886
  const own = new Set(entries.map((one) => one.page));
2879
2887
  const others = options.stations!.peers ? (await options.stations!.peers.entries()).filter((one) => !own.has(one.page)) : [];
2888
+ const rooms = options.stations!.rooms
2889
+ ? await roomEntries(options.stations!.rooms.store, options.stations!.rooms.probe, watchBase(options.stations!.publicBase?.() || `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`))
2890
+ : [];
2880
2891
  const ads = guideAds();
2881
- const page = renderGuide([...entries, ...others] as GuideEntry[], options.stations!.publicBase?.() ?? "", ads);
2892
+ const page = renderGuide([...rooms, ...entries, ...others] as GuideEntry[], options.stations!.publicBase?.() ?? "", ads);
2882
2893
  response.writeHead(200, {
2883
2894
  "content-type": "text/html; charset=utf-8",
2884
2895
  "cache-control": "no-cache",
@@ -2892,6 +2903,46 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2892
2903
 
2893
2904
  // The page a station's link opens. The same bytes for every station: it
2894
2905
  // reads its id and token from its own address.
2906
+ // A live room's page: /watch?play=<a nixamp server's channel or live
2907
+ // stream address>. The built page plays it in @profullstack/player, as a
2908
+ // film or as radio; nothing about the room passes through here.
2909
+ if (options.stations?.rooms && path === "/watch" && (request.method === "GET" || request.method === "HEAD")) {
2910
+ const built = web !== null && isFile(join(web, "watch.html")) ? readFileSync(join(web, "watch.html")) : null;
2911
+ if (!built) {
2912
+ json(response, 404, { error: "this server was started without its web pages" });
2913
+ return;
2914
+ }
2915
+ response.writeHead(200, {
2916
+ "content-type": "text/html; charset=utf-8",
2917
+ "cache-control": "no-cache",
2918
+ "content-security-policy": WATCH_APP_CSP,
2919
+ "referrer-policy": "no-referrer",
2920
+ "x-content-type-options": "nosniff",
2921
+ });
2922
+ response.end(request.method === "HEAD" ? undefined : built);
2923
+ return;
2924
+ }
2925
+
2926
+ if (options.stations?.rooms && (path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/"))) {
2927
+ const control = await controlOf();
2928
+ const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
2929
+ const rooms = options.stations.rooms;
2930
+ await handleRooms(request, response, path, {
2931
+ store: rooms.store,
2932
+ probe: rooms.probe,
2933
+ base: watchBase(options.stations.publicBase?.() || `https://${request.headers.host ?? "localhost"}`),
2934
+ canControl: control.allowed,
2935
+ member: async () => (session && options.owner ? (await options.owner.accountFor(session)) || "" : ""),
2936
+ json,
2937
+ readBody,
2938
+ throttle: () => {
2939
+ const verdict = guard.check(`rooms:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
2940
+ return verdict.ok ? null : { retryAfter: verdict.retryAfter };
2941
+ },
2942
+ });
2943
+ return;
2944
+ }
2945
+
2895
2946
  if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
2896
2947
  // The built page plays in @profullstack/player; the inline one is for a
2897
2948
  // server started without the web build.
@@ -3009,6 +3060,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3009
3060
  ...(options.stations.lyrics ? { lyrics: options.stations.lyrics } : {}),
3010
3061
  ...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}),
3011
3062
  ...(options.stations.peers ? { peers: () => options.stations!.peers!.entries() } : {}),
3063
+ ...(options.stations.rooms ? {
3064
+ rooms: () => roomEntries(options.stations!.rooms!.store, options.stations!.rooms!.probe, watchBase(options.stations!.publicBase?.() || `${scheme}://${request.headers.host ?? "localhost"}`)),
3065
+ } : {}),
3012
3066
  throttle: () => {
3013
3067
  const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
3014
3068
  return verdict.ok ? null : { retryAfter: verdict.retryAfter };
@@ -6430,7 +6484,24 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
6430
6484
  }
6431
6485
  const shell = subject ? readIfPossible(file) : null;
6432
6486
  if (subject && shell !== null) {
6433
- html(response, 200, joinDocument(shell, subject, eventSite ?? "https://nixamp.com"));
6487
+ html(response, 200, requestHost === "nixamp.com" ? withPitFooter(joinDocument(shell, subject, eventSite ?? "https://nixamp.com")) : joinDocument(shell, subject, eventSite ?? "https://nixamp.com"));
6488
+ return;
6489
+ }
6490
+ }
6491
+ // nixamp.com's own pages (the app shell, the radio console) carry the
6492
+ // pit.world global footer, appended on the way out. A local install
6493
+ // serving the same files on its own host is left alone.
6494
+ if (requestHost === "nixamp.com" && file.endsWith(".html") && (request.method === "GET" || request.method === "HEAD")) {
6495
+ const shell = readIfPossible(file);
6496
+ if (shell !== null) {
6497
+ const page = withPitFooter(shell);
6498
+ response.writeHead(200, {
6499
+ ...CORS,
6500
+ "content-type": "text/html; charset=utf-8",
6501
+ "content-length": Buffer.byteLength(page),
6502
+ "cache-control": "no-cache",
6503
+ });
6504
+ response.end(request.method === "HEAD" ? undefined : page);
6434
6505
  return;
6435
6506
  }
6436
6507
  }
@@ -7054,6 +7125,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7054
7125
  const listed = directory ? directory.list().map((one) => peerBase(one.url)).filter(Boolean).slice(0, 100) : [];
7055
7126
  return [...new Set([...guidePeers(), ...listed])];
7056
7127
  }),
7128
+ // Live rooms published to this guide; the probe never reaches this machine's own network.
7129
+ rooms: { store: new RoomStore(RoomStore.fileFor(stateDir(), options.port)), probe: new RoomProbe(fetch, (link) => refusedDestination(link)) },
7057
7130
  };
7058
7131
  // Music handed over from BitTorrented, copied into a folder of our own.
7059
7132
  const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
@@ -67,7 +67,7 @@ const SCRIPT = `
67
67
  } catch { button.textContent = "Could not report; try again"; button.disabled = false; }
68
68
  };
69
69
  const draw = (stations) => {
70
- for (const group of ["ai", "shared", "radio", "tv"]) {
70
+ for (const group of ["rooms", "ai", "shared", "radio", "tv"]) {
71
71
  const grid = document.querySelector("#" + group);
72
72
  grid.replaceChildren();
73
73
  const list = stations.filter((s) => (s.group || (s.kind === "video" ? "tv" : "radio")) === group);
@@ -154,8 +154,8 @@ export interface GuideEntry {
154
154
  id?: string;
155
155
  name: string;
156
156
  kind: "audio" | "video";
157
- /** ai, shared, radio or tv; from kind when absent. */
158
- group?: "ai" | "shared" | "radio" | "tv";
157
+ /** rooms (live rooms, src/guide-rooms.ts), ai, shared, radio or tv; from kind when absent. */
158
+ group?: "rooms" | "ai" | "shared" | "radio" | "tv";
159
159
  state: string;
160
160
  page: string;
161
161
  nowPlaying: { title: string; artist: string } | null;
@@ -163,7 +163,7 @@ export interface GuideEntry {
163
163
  peer?: boolean;
164
164
  }
165
165
 
166
- const GROUPS = ["ai", "shared", "radio", "tv"] as const;
166
+ const GROUPS = ["rooms", "ai", "shared", "radio", "tv"] as const;
167
167
 
168
168
  /**
169
169
  * The guide with its stations already in it, so it reads the same through a
@@ -186,7 +186,9 @@ export function renderGuide(entries: GuideEntry[], apiBase = "", ads: GuideAds =
186
186
  if (ads.site) page = page.replace("</head>", `<script data-site="${escape(ads.site)}" src="${CRAWLPROOF}/stats.js" async></script>\n</head>`);
187
187
  if (ads.slot) {
188
188
  const format = ads.format ?? "banner_300x250";
189
- page = page.replace(" <footer>", ` <div class="ad-unit ad-${format}"><iframe src="${CRAWLPROOF}/api/ads/frame?slot=${encodeURIComponent(ads.slot)}&amp;format=${format}" title="Advertisement" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>\n <footer>`);
189
+ const slot = ads.slot;
190
+ // The one ad goes at the top of the content, never against the footer.
191
+ page = page.replace('<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>\n', (lead) => `${lead} <div class="ad-unit ad-${format}"><iframe src="${CRAWLPROOF}/api/ads/frame?slot=${encodeURIComponent(slot)}&amp;format=${format}" title="Advertisement" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>\n`);
190
192
  }
191
193
  const api = /^https:\/\/[^\s"<>]+$/.test(apiBase) ? apiBase.replace(/\/+$/, "") : "";
192
194
  return (page.replace('<p id="empty" class="empty">', entries.length ? '<p id="empty" class="empty" hidden>' : '<p id="empty" class="empty">')).replace("<body>", api ? `<body data-api="${escape(api)}">` : "<body>");
@@ -206,6 +208,8 @@ export const GUIDE_HTML = `<!doctype html>
206
208
  <main>
207
209
  <h1>Live</h1>
208
210
  <p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
211
+ <h2 id="rooms-title" hidden>Live rooms</h2>
212
+ <div id="rooms" class="grid" aria-live="polite"></div>
209
213
  <h2 id="ai-title" hidden>AI Radio</h2>
210
214
  <div id="ai" class="grid" aria-live="polite"></div>
211
215
  <h2 id="shared-title" hidden>Shared by listeners</h2>
@@ -237,7 +241,7 @@ export const GUIDE_HTML = `<!doctype html>
237
241
  <button type="submit">Share it</button>
238
242
  <div id="status" role="status" aria-live="polite"></div>
239
243
  </form>
240
- <footer>Played by <a href="https://nixamp.com">nixamp</a>, on the Moshpit.</footer>
244
+ <footer>Played by <a href="https://nixamp.com">nixamp</a>.</footer>
241
245
  </main>
242
246
  <script>${SCRIPT}</script>
243
247
  </body>
@@ -217,6 +217,22 @@ export const STATION_PAGE_CSP = [
217
217
  ].join("; ");
218
218
 
219
219
  /** The built page: its own bundle, styles and player engines, only this server, never framed. */
220
+ /**
221
+ * A live room's page (/watch): the same built page shape, but what it plays and
222
+ * asks about is on the room's own server, any https nixamp server.
223
+ */
224
+ export const WATCH_APP_CSP = [
225
+ "default-src 'self'",
226
+ "style-src 'self' 'unsafe-inline'",
227
+ "media-src 'self' blob: https:",
228
+ "worker-src 'self' blob:",
229
+ "img-src 'self' data: blob: https:",
230
+ "connect-src 'self' https:",
231
+ "base-uri 'none'",
232
+ "form-action 'none'",
233
+ "frame-ancestors 'none'",
234
+ ].join("; ");
235
+
220
236
  export const STATION_APP_CSP = [
221
237
  "default-src 'self'",
222
238
  "style-src 'self' 'unsafe-inline'",
@@ -74,6 +74,8 @@ export interface StationContext {
74
74
  auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
75
75
  /** The public stations other servers list (guide peers); absent when there are none. */
76
76
  peers?: () => Promise<unknown[]>;
77
+ /** Live rooms members published to this guide (guide-rooms.ts); absent when this server keeps none. */
78
+ rooms?: () => Promise<unknown[]>;
77
79
  }
78
80
 
79
81
  function fail(ctx: StationContext, response: ServerResponse, error: unknown): void {
@@ -181,7 +183,9 @@ export async function handleStations(
181
183
  const shown = new Set(own.map((one) => one.page));
182
184
  const others = url.searchParams.has("local") || !ctx.peers ? [] : (await ctx.peers())
183
185
  .filter((one) => !shown.has((one as { page: string }).page));
184
- ctx.json(response, 200, { stations: [...own, ...others] });
186
+ // Rooms are this guide's own; a peer asking for its stations does not list them twice.
187
+ const rooms = url.searchParams.has("local") || !ctx.rooms ? [] : await ctx.rooms();
188
+ ctx.json(response, 200, { stations: [...rooms, ...own, ...others] });
185
189
  return true;
186
190
  }
187
191
 
@@ -1 +1 @@
1
- import{t as e}from"./main-CnCKUBjG.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-B__9rtDL.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};