nixamp 0.13.1 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio.d.ts +6 -0
- package/dist/audio.js +5 -1
- package/dist/channels.d.ts +55 -2
- package/dist/channels.js +89 -7
- package/dist/server.d.ts +17 -1
- package/dist/server.js +75 -39
- package/package.json +1 -1
- package/src/audio.ts +12 -2
- package/src/channels.ts +123 -11
- package/src/server.ts +84 -40
- package/web/dist/assets/{hls-3VKVEQE3-DuMhmqXR.js → hls-3VKVEQE3-YPP6OTXj.js} +1 -1
- package/web/dist/assets/{index-B2PmEiyU.css → index-B5BiSCIQ.css} +1 -1
- package/web/dist/assets/{index-C8bIVJ3F.js → index-D2_3UUpZ.js} +1 -1
- package/web/dist/assets/{mpegts-C4QEghY-.js → mpegts-Bz2AeKRm.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DdP4CfuQ.js → mpegts-LO6RVLD6-DQp9wz3i.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +6 -6
package/src/channels.ts
CHANGED
|
@@ -50,8 +50,33 @@ export interface ChannelInfo {
|
|
|
50
50
|
error?: string;
|
|
51
51
|
/** How many times the source has been dialled again since it started. */
|
|
52
52
|
redials?: number;
|
|
53
|
+
/**
|
|
54
|
+
* For a pulled film: how far into it we are, in seconds, read off ffmpeg
|
|
55
|
+
* as it goes. This is what a restart and a redial go back to. A live
|
|
56
|
+
* source has no such place, and says so with `live`.
|
|
57
|
+
*/
|
|
58
|
+
position?: number;
|
|
59
|
+
live?: boolean;
|
|
60
|
+
/** What the source turned out to hold, so a restart need not ask again. */
|
|
61
|
+
codecs?: { video: string; audio: string; container: string; duration?: number };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Where a pulled source is picked up from, and whether it can be at all. */
|
|
65
|
+
export interface PullResume {
|
|
66
|
+
/** A live source is joined where it is now; a film is joined where it was. */
|
|
67
|
+
live: boolean;
|
|
68
|
+
/** Seconds into a film to start from. */
|
|
69
|
+
position: number;
|
|
53
70
|
}
|
|
54
71
|
|
|
72
|
+
/**
|
|
73
|
+
* How far back of the saved place a film is picked up from, in seconds. The
|
|
74
|
+
* place is written down every so often and a restart lands between two
|
|
75
|
+
* writes; a few seconds repeated is a hiccup, a few seconds missed is a
|
|
76
|
+
* line of dialogue.
|
|
77
|
+
*/
|
|
78
|
+
export const REWIND = 3;
|
|
79
|
+
|
|
55
80
|
/** How long to wait before dialling a dropped source again. */
|
|
56
81
|
export const REDIAL = 2000;
|
|
57
82
|
/** How many times in a row a source may fail without ever sending anything. */
|
|
@@ -219,11 +244,21 @@ export class Channel {
|
|
|
219
244
|
* because you looked away, and a room where the picture depends on who is
|
|
220
245
|
* in it is not a room anybody can be invited to.
|
|
221
246
|
*/
|
|
222
|
-
pull(
|
|
247
|
+
pull(
|
|
248
|
+
source: string,
|
|
249
|
+
encode: string[],
|
|
250
|
+
paced = true,
|
|
251
|
+
stall = STALL,
|
|
252
|
+
input: string[] = [],
|
|
253
|
+
audio = "",
|
|
254
|
+
resume: PullResume = { live: true, position: 0 },
|
|
255
|
+
): void {
|
|
223
256
|
this.stall = stall;
|
|
224
257
|
if (this.info.kind === "video") this.fragments = new Fragments();
|
|
225
258
|
const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
|
|
226
259
|
const remote = /^https?:\/\//i.test(source);
|
|
260
|
+
this.info.live = resume.live;
|
|
261
|
+
if (!resume.live) this.info.position = Math.max(0, resume.position);
|
|
227
262
|
// What every remote input is told: dial again when the CDN drops it, and
|
|
228
263
|
// give up on a socket that has gone quiet. Input options apply to the
|
|
229
264
|
// input that follows them, so a second input is told again.
|
|
@@ -234,12 +269,23 @@ export class Channel {
|
|
|
234
269
|
const dial = (): void => {
|
|
235
270
|
if (this.closing) return;
|
|
236
271
|
this.stderr = "";
|
|
272
|
+
// Where to pick a film up from: a little before where it was, since
|
|
273
|
+
// the place was written down a moment ago and a moment of it twice is
|
|
274
|
+
// better than a moment of it missing. A live source is joined as is,
|
|
275
|
+
// and a film that has barely started is started.
|
|
276
|
+
const from = resume.live ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
|
|
277
|
+
const seek = from > 0 ? ["-ss", String(from)] : [];
|
|
237
278
|
const child = spawn(
|
|
238
279
|
command,
|
|
239
280
|
[
|
|
240
281
|
...prefix,
|
|
241
282
|
"-hide_banner",
|
|
242
283
|
"-loglevel", "error",
|
|
284
|
+
// How far it has got, once a second, on a pipe of its own: this is
|
|
285
|
+
// what is written down for a restart and what a redial goes back
|
|
286
|
+
// to. Not stderr, which is for what went wrong.
|
|
287
|
+
"-progress", "pipe:3",
|
|
288
|
+
"-stats_period", "1",
|
|
243
289
|
// A dropped source is normal over hours, and a channel that dies
|
|
244
290
|
// the first time a CDN hiccups is not a channel anybody can rely
|
|
245
291
|
// on. ffmpeg redials on its own before we have to.
|
|
@@ -256,19 +302,36 @@ export class Channel {
|
|
|
256
302
|
// referer, a cookie. A link resolved by yt-dlp comes with these,
|
|
257
303
|
// and a CDN that got them from yt-dlp and not from us answers 403.
|
|
258
304
|
...input,
|
|
305
|
+
...seek,
|
|
259
306
|
"-i", source,
|
|
260
307
|
// The sound, when the site keeps it apart from the picture: a
|
|
261
308
|
// second input, dialled the same way, that the encode maps in.
|
|
262
|
-
...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, "-i", audio] : []),
|
|
309
|
+
...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
|
|
263
310
|
...encode,
|
|
264
311
|
"pipe:1",
|
|
265
312
|
],
|
|
266
|
-
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
313
|
+
{ stdio: ["ignore", "pipe", "pipe", "pipe"] },
|
|
267
314
|
);
|
|
268
315
|
|
|
269
316
|
let sent = false;
|
|
270
317
|
this.child = child;
|
|
271
318
|
this.rearm(child);
|
|
319
|
+
// ffmpeg's progress: key=value lines, out_time_us being how much it
|
|
320
|
+
// has written, from where it was told to start. Read whole lines,
|
|
321
|
+
// since a chunk can end mid-number. Drained whatever it says, for
|
|
322
|
+
// the same reason stderr is.
|
|
323
|
+
let progress = "";
|
|
324
|
+
(child.stdio[3] as Readable | null)?.on("data", (chunk: Buffer) => {
|
|
325
|
+
progress = (progress + chunk.toString("utf8")).slice(-4000);
|
|
326
|
+
if (this.child !== child || resume.live) return;
|
|
327
|
+
const lines = progress.split("\n");
|
|
328
|
+
progress = lines.pop() ?? "";
|
|
329
|
+
for (const line of lines) {
|
|
330
|
+
const match = /^out_time_us=(\d+)/.exec(line.trim());
|
|
331
|
+
if (match) this.info.position = from + Number(match[1]) / 1e6;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
(child.stdio[3] as Readable | null)?.on("error", () => undefined);
|
|
272
335
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
273
336
|
// An ffmpeg that was replaced can still have a chunk in the pipe.
|
|
274
337
|
if (this.child !== child) return;
|
|
@@ -626,6 +689,7 @@ export class Channels {
|
|
|
626
689
|
stall = STALL,
|
|
627
690
|
input: string[] = [],
|
|
628
691
|
audio = "",
|
|
692
|
+
resume: PullResume = { live: true, position: 0 },
|
|
629
693
|
): Channel | null {
|
|
630
694
|
if (this.open.has(id)) return null;
|
|
631
695
|
const channel = new Channel(
|
|
@@ -644,7 +708,7 @@ export class Channels {
|
|
|
644
708
|
(gone) => this.open.delete(gone),
|
|
645
709
|
);
|
|
646
710
|
this.open.set(id, channel);
|
|
647
|
-
channel.pull(source, encode, paced, stall, input, audio);
|
|
711
|
+
channel.pull(source, encode, paced, stall, input, audio, resume);
|
|
648
712
|
return channel;
|
|
649
713
|
}
|
|
650
714
|
|
|
@@ -758,6 +822,19 @@ export interface RememberedChannel {
|
|
|
758
822
|
id: string;
|
|
759
823
|
name: string;
|
|
760
824
|
source: string;
|
|
825
|
+
/**
|
|
826
|
+
* What it was, so a restart need not ask the source again. Asking is not
|
|
827
|
+
* free: a film on an IPTV panel allows one connection, and while the old
|
|
828
|
+
* ffmpeg's is still being counted a probe of it gets an error page and no
|
|
829
|
+
* streams -- which read as "no picture", and two films came back on the
|
|
830
|
+
* air as sound alone.
|
|
831
|
+
*/
|
|
832
|
+
kind?: "audio" | "video";
|
|
833
|
+
codecs?: { video: string; audio: string; container: string; duration?: number };
|
|
834
|
+
/** Where a film had got to, in seconds, so it picks up there. */
|
|
835
|
+
position?: number;
|
|
836
|
+
/** A live source has nowhere to pick up from. */
|
|
837
|
+
live?: boolean;
|
|
761
838
|
}
|
|
762
839
|
|
|
763
840
|
const REMEMBERED = "channels.json";
|
|
@@ -767,18 +844,53 @@ export function rememberedChannels(dir: string, port: number): RememberedChannel
|
|
|
767
844
|
const all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8")) as Record<string, unknown>;
|
|
768
845
|
const list = all[String(port)];
|
|
769
846
|
if (!Array.isArray(list)) return [];
|
|
770
|
-
return list
|
|
771
|
-
(
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
847
|
+
return list
|
|
848
|
+
.filter(
|
|
849
|
+
(one): one is Record<string, unknown> =>
|
|
850
|
+
typeof one === "object" && one !== null &&
|
|
851
|
+
typeof (one as RememberedChannel).id === "string" &&
|
|
852
|
+
typeof (one as RememberedChannel).name === "string" &&
|
|
853
|
+
typeof (one as RememberedChannel).source === "string",
|
|
854
|
+
)
|
|
855
|
+
.map((one) => {
|
|
856
|
+
const kept: RememberedChannel = { id: one["id"] as string, name: one["name"] as string, source: one["source"] as string };
|
|
857
|
+
if (one["kind"] === "audio" || one["kind"] === "video") kept.kind = one["kind"];
|
|
858
|
+
const codecs = one["codecs"];
|
|
859
|
+
if (codecs && typeof codecs === "object") {
|
|
860
|
+
const c = codecs as Record<string, unknown>;
|
|
861
|
+
if (typeof c["video"] === "string" && typeof c["audio"] === "string" && typeof c["container"] === "string") {
|
|
862
|
+
kept.codecs = { video: c["video"], audio: c["audio"], container: c["container"] };
|
|
863
|
+
if (typeof c["duration"] === "number" && Number.isFinite(c["duration"])) kept.codecs.duration = c["duration"];
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
if (typeof one["position"] === "number" && Number.isFinite(one["position"]) && one["position"] > 0) kept.position = one["position"];
|
|
867
|
+
if (typeof one["live"] === "boolean") kept.live = one["live"];
|
|
868
|
+
return kept;
|
|
869
|
+
});
|
|
777
870
|
} catch {
|
|
778
871
|
return [];
|
|
779
872
|
}
|
|
780
873
|
}
|
|
781
874
|
|
|
875
|
+
/**
|
|
876
|
+
* What to write down about the channels a server is carrying: the pulled,
|
|
877
|
+
* kept ones, with what they turned out to be and where they have got to.
|
|
878
|
+
* The same answer everywhere one is added, kept, or taken off, so that no
|
|
879
|
+
* path forgets the position.
|
|
880
|
+
*/
|
|
881
|
+
export function rememberedNow(channels: Channels): RememberedChannel[] {
|
|
882
|
+
return channels.list()
|
|
883
|
+
.filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id))
|
|
884
|
+
.map((one) => {
|
|
885
|
+
const kept: RememberedChannel = { id: one.id, name: one.name, source: one.source as string };
|
|
886
|
+
if (one.kind) kept.kind = one.kind;
|
|
887
|
+
if (one.codecs) kept.codecs = one.codecs;
|
|
888
|
+
if (typeof one.live === "boolean") kept.live = one.live;
|
|
889
|
+
if (!one.live && typeof one.position === "number" && one.position > 0) kept.position = Math.floor(one.position);
|
|
890
|
+
return kept;
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
782
894
|
export function rememberChannels(dir: string, port: number, list: RememberedChannel[]): void {
|
|
783
895
|
let all: Record<string, unknown> = {};
|
|
784
896
|
try {
|
package/src/server.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from "./broadcast.ts";
|
|
28
28
|
import { Ingest, normaliseFormat } from "./ingest.ts";
|
|
29
29
|
import {
|
|
30
|
-
Channels, cleanId, generatedId, rememberChannels, rememberedChannels,
|
|
30
|
+
Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow,
|
|
31
31
|
type Channel, type RememberedChannel,
|
|
32
32
|
} from "./channels.ts";
|
|
33
33
|
import { RtmpListeners } from "./rtmp-in.ts";
|
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
paywallFromEnv,
|
|
82
82
|
} from "./paywall.ts";
|
|
83
83
|
import { isRemote, playsInBrowser, sourceLabel } from "./sources.ts";
|
|
84
|
-
import { codecsOf, probeAsync, videoArgs } from "./audio.ts";
|
|
84
|
+
import { codecsOf, probeAsync, videoArgs, type Codecs } from "./audio.ts";
|
|
85
85
|
import {
|
|
86
86
|
allowedForListening,
|
|
87
87
|
elevate,
|
|
@@ -972,6 +972,12 @@ export async function answerWith(response: ServerResponse, refused: Response): P
|
|
|
972
972
|
*/
|
|
973
973
|
export const MAX_ON_DEMAND = 4;
|
|
974
974
|
|
|
975
|
+
/**
|
|
976
|
+
* How often where each film has got to is written down. A restart lands
|
|
977
|
+
* somewhere inside this, and REWIND covers the gap.
|
|
978
|
+
*/
|
|
979
|
+
export const REMEMBER_EVERY_MS = 15_000;
|
|
980
|
+
|
|
975
981
|
/**
|
|
976
982
|
* Probe a source and start carrying it as a channel of its own.
|
|
977
983
|
*
|
|
@@ -979,6 +985,18 @@ export const MAX_ON_DEMAND = 4;
|
|
|
979
985
|
* ones back, so that both agree on what a source is encoded as. Null when
|
|
980
986
|
* that channel id is already on.
|
|
981
987
|
*/
|
|
988
|
+
/** What is already known about a source, so it need not be asked, or asked twice. */
|
|
989
|
+
export interface KnownSource {
|
|
990
|
+
kind?: "audio" | "video";
|
|
991
|
+
codecs?: Codecs;
|
|
992
|
+
position?: number;
|
|
993
|
+
live?: boolean;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** How many times a source that answers nothing is asked, and how far apart. */
|
|
997
|
+
export const PROBE_TRIES = 3;
|
|
998
|
+
export const PROBE_RETRY_MS = 1500;
|
|
999
|
+
|
|
982
1000
|
export async function pullChannel(
|
|
983
1001
|
channels: Channels,
|
|
984
1002
|
ffprobe: string[],
|
|
@@ -987,17 +1005,42 @@ export async function pullChannel(
|
|
|
987
1005
|
source: string,
|
|
988
1006
|
input: string[] = [],
|
|
989
1007
|
audio = "",
|
|
1008
|
+
known: KnownSource = {},
|
|
990
1009
|
): Promise<Channel | null> {
|
|
991
1010
|
const tools = { ffmpeg: [], ffprobe, play: null };
|
|
1011
|
+
const empty = (c: Codecs): boolean => c.video === "" && c.audio === "";
|
|
992
1012
|
// A pair is probed as a pair: the picture's file has no sound in it, and
|
|
993
1013
|
// asked alone it would read as a silent film. The sound's codec comes from
|
|
994
1014
|
// the sound's file; the picture's, and the container, from the picture's.
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1015
|
+
const probe = async (): Promise<Codecs> => {
|
|
1016
|
+
const [picture, sound] = await Promise.all([
|
|
1017
|
+
codecsOf(tools, source, input),
|
|
1018
|
+
audio ? codecsOf(tools, audio, input) : Promise.resolve(null),
|
|
1019
|
+
]);
|
|
1020
|
+
return sound ? { ...picture, audio: sound.audio } : picture;
|
|
1021
|
+
};
|
|
1022
|
+
// Known already: a restart puts back what it wrote down and asks nobody.
|
|
1023
|
+
// Otherwise ask, and ask again when the answer is nothing: a film on an
|
|
1024
|
+
// IPTV panel allows one connection, and while the last ffmpeg's is still
|
|
1025
|
+
// being counted a probe gets an error page and no streams. Nothing, read
|
|
1026
|
+
// as "no picture", is how two films came back on the air as sound alone.
|
|
1027
|
+
let codecs: Codecs = known.codecs && !empty(known.codecs) ? known.codecs : { video: "", audio: "", container: "" };
|
|
1028
|
+
let assumed = false;
|
|
1029
|
+
if (empty(codecs)) {
|
|
1030
|
+
for (let attempt = 0; attempt < PROBE_TRIES; attempt++) {
|
|
1031
|
+
if (attempt > 0) await new Promise((r) => setTimeout(r, PROBE_RETRY_MS));
|
|
1032
|
+
codecs = await probe();
|
|
1033
|
+
if (!empty(codecs)) break;
|
|
1034
|
+
}
|
|
1035
|
+
assumed = empty(codecs);
|
|
1036
|
+
}
|
|
1037
|
+
// A source that would not say is carried as what it was last time, or as
|
|
1038
|
+
// video: a picture-less MP4 still plays, whereas a film as MP3 is a film
|
|
1039
|
+
// with no picture until somebody notices.
|
|
1040
|
+
const kind = codecs.video !== "" ? "video" : codecs.audio !== "" ? "audio" : (known.kind ?? "video");
|
|
1041
|
+
if (assumed) console.log(` "${id}": the source would not say what it holds; carrying it as ${kind}.`);
|
|
1042
|
+
// A film has a length and a place to go back to; a live source has neither.
|
|
1043
|
+
const live = known.live ?? !((codecs.duration ?? 0) > 0);
|
|
1001
1044
|
const encode = kind === "video"
|
|
1002
1045
|
? [
|
|
1003
1046
|
// Which streams from which input, when there are two: the picture
|
|
@@ -1009,7 +1052,12 @@ export async function pullChannel(
|
|
|
1009
1052
|
// No picture in it, so none is invented: MP3 is the thing every browser
|
|
1010
1053
|
// plays and the thing a listener can join halfway through.
|
|
1011
1054
|
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
1012
|
-
|
|
1055
|
+
const channel = channels.pull(
|
|
1056
|
+
id, name, source, encode, kind, true, undefined, input, kind === "video" ? audio : "",
|
|
1057
|
+
{ live, position: known.position ?? 0 },
|
|
1058
|
+
);
|
|
1059
|
+
if (channel && !assumed) channel.info.codecs = codecs;
|
|
1060
|
+
return channel;
|
|
1013
1061
|
}
|
|
1014
1062
|
|
|
1015
1063
|
/** What a link resolves to, kept so a download can be named without asking twice. */
|
|
@@ -2770,13 +2818,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2770
2818
|
}
|
|
2771
2819
|
}
|
|
2772
2820
|
options.channels.keep(channelId);
|
|
2773
|
-
|
|
2774
|
-
options.rememberChannels(
|
|
2775
|
-
options.channels.list()
|
|
2776
|
-
.filter((one) => one.via === "pull" && one.source && !options.channels?.isEphemeral(one.id))
|
|
2777
|
-
.map((one) => ({ id: one.id, name: one.name, source: one.source as string })),
|
|
2778
|
-
);
|
|
2779
|
-
}
|
|
2821
|
+
options.rememberChannels?.(rememberedNow(options.channels));
|
|
2780
2822
|
void options.live?.announce?.();
|
|
2781
2823
|
json(response, 200, { channel: channelId, name: entry.title, kind: entry.live ? "live" : "vod" });
|
|
2782
2824
|
return;
|
|
@@ -3066,13 +3108,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3066
3108
|
const stopped = channels.stop(id);
|
|
3067
3109
|
// Taken off on purpose is forgotten on purpose: it must not come back
|
|
3068
3110
|
// at the next restart.
|
|
3069
|
-
if (stopped
|
|
3070
|
-
options.rememberChannels(
|
|
3071
|
-
channels.list()
|
|
3072
|
-
.filter((one) => one.via === "pull" && one.source)
|
|
3073
|
-
.map((one) => ({ id: one.id, name: one.name, source: one.source as string })),
|
|
3074
|
-
);
|
|
3075
|
-
}
|
|
3111
|
+
if (stopped) options.rememberChannels?.(rememberedNow(channels));
|
|
3076
3112
|
json(response, stopped ? 200 : 404, { ok: stopped });
|
|
3077
3113
|
return;
|
|
3078
3114
|
}
|
|
@@ -3116,13 +3152,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3116
3152
|
return;
|
|
3117
3153
|
}
|
|
3118
3154
|
channels.keep(id);
|
|
3119
|
-
|
|
3120
|
-
options.rememberChannels(
|
|
3121
|
-
channels.list()
|
|
3122
|
-
.filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id))
|
|
3123
|
-
.map((one) => ({ id: one.id, name: one.name, source: one.source as string })),
|
|
3124
|
-
);
|
|
3125
|
-
}
|
|
3155
|
+
options.rememberChannels?.(rememberedNow(channels));
|
|
3126
3156
|
void options.live?.announce?.();
|
|
3127
3157
|
json(response, 200, { ok: true });
|
|
3128
3158
|
return;
|
|
@@ -3178,14 +3208,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3178
3208
|
return;
|
|
3179
3209
|
}
|
|
3180
3210
|
// Written down, so a restart puts it back on the air.
|
|
3181
|
-
|
|
3182
|
-
options.rememberChannels([
|
|
3183
|
-
...channels.list()
|
|
3184
|
-
.filter((one) => one.via === "pull" && one.source && one.id !== wanted)
|
|
3185
|
-
.map((one) => ({ id: one.id, name: one.name, source: one.source as string })),
|
|
3186
|
-
{ id: wanted, name: called, source },
|
|
3187
|
-
]);
|
|
3188
|
-
}
|
|
3211
|
+
options.rememberChannels?.(rememberedNow(channels));
|
|
3189
3212
|
json(response, 200, { ok: true, channel: channel.info });
|
|
3190
3213
|
return;
|
|
3191
3214
|
}
|
|
@@ -4148,11 +4171,32 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4148
4171
|
// every restart used to take CNN off the air until somebody noticed.
|
|
4149
4172
|
const remembering = (list: RememberedChannel[]): void => rememberChannels(stateDir(), options.port, list);
|
|
4150
4173
|
for (const one of rememberedChannels(stateDir(), options.port)) {
|
|
4151
|
-
|
|
4152
|
-
|
|
4174
|
+
const where = one.position && !one.live ? `, from ${Math.floor(one.position / 60)}m${Math.floor(one.position % 60)}s` : "";
|
|
4175
|
+
console.log(` Putting "${one.id}" (${one.name}) back on the air${where}.`);
|
|
4176
|
+
// With what was written down about it: what it holds, so the source is
|
|
4177
|
+
// not asked again, and where it had got to, so a film carries on.
|
|
4178
|
+
void pullChannel(channels, tools.ffprobe, one.id, one.name, one.source, [], "", {
|
|
4179
|
+
...(one.kind ? { kind: one.kind } : {}),
|
|
4180
|
+
...(one.codecs ? { codecs: one.codecs } : {}),
|
|
4181
|
+
...(one.position !== undefined ? { position: one.position } : {}),
|
|
4182
|
+
...(one.live !== undefined ? { live: one.live } : {}),
|
|
4183
|
+
}).then((channel) => {
|
|
4153
4184
|
if (!channel) console.log(` "${one.id}" is already on.`);
|
|
4154
4185
|
});
|
|
4155
4186
|
}
|
|
4187
|
+
// Where each film has got to, written down every so often, so a restart
|
|
4188
|
+
// picks it up from about there rather than from the start. Only when it
|
|
4189
|
+
// has changed: a server carrying nothing but live television writes
|
|
4190
|
+
// nothing.
|
|
4191
|
+
let lastRemembered = "";
|
|
4192
|
+
setInterval(() => {
|
|
4193
|
+
const now = rememberedNow(channels);
|
|
4194
|
+
if (!now.some((one) => one.position !== undefined)) return;
|
|
4195
|
+
const text = JSON.stringify(now);
|
|
4196
|
+
if (text === lastRemembered) return;
|
|
4197
|
+
lastRemembered = text;
|
|
4198
|
+
remembering(now);
|
|
4199
|
+
}, REMEMBER_EVERY_MS).unref();
|
|
4156
4200
|
|
|
4157
4201
|
// The m3u catalogs kept here: read from disk now, and any that were never
|
|
4158
4202
|
// read are fetched in the background so browsing does not wait on a provider.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-D2_3UUpZ.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
:root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}[hidden]{display:none!important}html,body{background:var(--bg);min-height:100%;color:var(--fg);margin:0;font:14px/1.45 ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}body{background-image:repeating-linear-gradient(#4af68906 0 1px,#0000 1px 3px)}#app{max-width:1100px;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));flex-direction:column;gap:10px;margin:0 auto;display:flex}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.status[data-playing=loading]{color:var(--accent)}.status[data-playing=loading]:before,.row.loading>.name:after{content:"";vertical-align:-.1em;border:2px solid;border-right-color:#0000;border-radius:50%;width:.8em;height:.8em;animation:.8s linear infinite turn;display:inline-block}.status[data-playing=loading]:before{margin-right:6px}.row.loading>.name:after{color:var(--accent);margin-left:8px}@keyframes turn{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.status[data-playing=loading]:before,.row.loading>.name:after{animation-duration:2.4s}}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{border:1px solid var(--edge);background:var(--panel);border-radius:6px;min-width:0;padding:14px 12px 12px;position:relative}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;padding:0 6px;font-size:12px;position:absolute;top:-.72em;left:10px}.split{grid-template-columns:1.3fr 1fr;gap:10px;display:grid}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}.stack{align-content:start;gap:14px;min-width:0;display:grid}.directory-list li.onair{border-bottom:1px solid var(--edge);flex-direction:column;align-items:stretch;gap:6px;padding:6px 0}.directory-list li.onair:last-child{border-bottom:0}.directory-list li.onair>.recent-label{flex:none}.onair-actions{flex-wrap:wrap;align-items:center;gap:6px;display:flex}.icon{border:1px solid var(--edge);min-width:2.2em;height:2.2em;color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;font-size:1em;line-height:1}.icon:hover{color:var(--accent);border-color:var(--accent)}.directory-list li>.button:disabled,.directory-list li>.way-in:disabled{opacity:.4;cursor:not-allowed}.directory-list li>.way-in:disabled:hover{color:var(--muted);border-color:var(--edge)}.directory-list li>.way-in{color:var(--fg);flex:none;align-self:center;padding:0 8px}.directory-list li>.way-in svg{width:18px;height:18px}.heart{color:var(--muted);cursor:pointer;background:0 0;border:1px solid #0000;border-radius:3px;padding:2px 6px;font-size:1.1em;line-height:1}.heart:hover{color:var(--accent);border-color:var(--accent)}.heart[data-on=yes]{color:var(--accent)}.directory-list li>span.server-label{flex-direction:column;flex:1 1 0;gap:2px;min-width:0;padding:4px 2px;display:flex}.directory-list li>.server-label .name{font-weight:600}.directory-list li>.server-label .detail{color:var(--muted);white-space:nowrap}.directory-list li>.server-label .live{color:var(--green)}@media (max-width:720px){.split{grid-template-columns:1fr}.split>.col-a,.split>.col-b{grid-column:auto}}.track-title{color:var(--accent);text-overflow:ellipsis;white-space:nowrap;font-weight:700;overflow:hidden}.track-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.track-live{color:var(--muted);overflow-wrap:anywhere;margin-top:6px;line-height:1.5}.track-live b{color:var(--green);font-weight:700}.track-meta{flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px;margin-bottom:4px;display:flex}.meta-chip{color:var(--muted);border:1px solid var(--edge);letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:100%;padding:0 8px;font-size:11px;line-height:1.7;overflow:hidden}.meta-chip:first-of-type{color:var(--green);border-color:var(--green-dim)}.meta-logo{object-fit:contain;background:#000;border-radius:3px;max-width:64px;height:22px}.meta-logo.meta-poster{max-width:52px;height:72px}.meta-score{flex-wrap:wrap;flex-basis:100%;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:1.4;display:flex}.meta-team{color:var(--fg);text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:38vw;overflow:hidden}.meta-points{font-variant-numeric:tabular-nums;font-size:16px;font-weight:700}.meta-dash{color:var(--muted);padding:0 2px}.meta-team-logo{object-fit:contain;flex:none;width:20px;height:20px}.meta-chip.chip-live{color:var(--fg);border-color:#b3261e}.meta-chip.chip-live:before{content:"";vertical-align:1px;background:#e53935;border-radius:50%;width:7px;height:7px;margin-right:5px;animation:1.4s ease-in-out infinite pulse;display:inline-block}@keyframes pulse{50%{opacity:.35}}@media (prefers-reduced-motion:reduce){.meta-chip.chip-live:before{animation:none}}.track-blurb{color:var(--muted);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin:4px 0 0;font-size:12px;line-height:1.5;display:-webkit-box;overflow:hidden}#video{border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px}.scrub{align-items:center;gap:10px;margin-top:6px;display:flex}.time{color:var(--fg);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}input[type=range]{appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:14px}input[type=range]::-webkit-slider-runnable-track{background:linear-gradient(var(--edge), var(--edge));border:1px solid var(--edge);border-radius:3px;height:6px}input[type=range]::-moz-range-track{background:var(--edge);border-radius:3px;height:6px}input[type=range]::-webkit-slider-thumb{appearance:none;background:var(--green);border-radius:2px;width:10px;height:16px;margin-top:-6px}input[type=range]::-moz-range-thumb{background:var(--green);border:0;border-radius:2px;width:10px;height:16px}input[type=range]:disabled{opacity:.45;cursor:default}#spectrum{border:1px solid var(--edge);background:#060a07;border-radius:4px;width:100%;height:190px;display:block}.meters{align-items:baseline;gap:10px;margin-top:6px;display:flex;overflow:hidden}.glyphs{color:var(--green);letter-spacing:1px;white-space:pre;flex:auto;min-width:0;min-height:1.4em;font-family:ui-monospace,SF Mono,Menlo,Consolas,monospace;overflow:hidden}.levelmeter{color:var(--accent);white-space:pre;font-variant-ligatures:none;text-align:right;flex:none;min-width:15ch;margin-left:auto;font-family:ui-monospace,SF Mono,Menlo,Consolas,monospace}.filter{border:1px solid var(--edge);width:100%;color:var(--fg);font:inherit;background:#0a100c;border-radius:4px;margin-bottom:6px;padding:4px 8px}.filter:focus{border-color:var(--accent);outline:none}.crumbs{color:var(--muted);flex-wrap:wrap;align-items:center;gap:4px;margin-bottom:6px;font-size:12px;display:flex}.crumbs button{font:inherit;color:var(--accent);cursor:pointer;background:0 0;border:0;padding:0 2px}.crumbs button:hover{text-decoration:underline}.crumbs .here{color:var(--fg)}.catalog-tag{color:var(--muted);letter-spacing:.06em;flex:none;font-size:11px}.catalog-live{color:var(--green)}#catalogs-entries{max-height:320px;overflow-y:auto}.folder{cursor:pointer;white-space:nowrap;color:var(--accent);border-radius:3px;gap:8px;padding:2px 6px;display:flex}.folder:hover{background:#142019}.folder .name{text-overflow:ellipsis;flex:1;overflow:hidden}.folder .count{color:var(--muted);flex:none}.playlist{scrollbar-color:var(--green-dim) transparent;max-height:214px;margin:0;padding:0;list-style:none;overflow-y:auto}.row{cursor:pointer;white-space:nowrap;border-radius:3px;gap:8px;padding:2px 6px;display:flex}.row:hover{background:#142019}.row.selected{background:#16241c}.row.selected .name{color:var(--accent)}.row.playing .name{color:var(--green)}.group{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--edge);align-items:center;gap:8px;margin-top:4px;padding:6px 6px 2px;font-size:11px;display:flex}.group:first-child{border-top:0;margin-top:0}.group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.group-remove{border:1px solid var(--edge);color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;line-height:1.4}.group-remove:hover{color:var(--accent);border-color:var(--accent)}.row .n{color:var(--muted);text-align:right;flex:none;width:2.4em}.row .name{text-overflow:ellipsis;flex:1;overflow:hidden}.row .time{color:var(--muted);flex:none}.row-copy{color:var(--muted);cursor:pointer;opacity:.55;background:0 0;border:1px solid #0000;border-radius:3px;flex:none;padding:0 5px;line-height:1.4}.row:hover .row-copy,.row.selected .row-copy{opacity:1}.row-copy svg,.icon svg{vertical-align:-2px;width:14px;height:14px;display:inline-block}.icon{justify-content:center;align-items:center;display:inline-flex}.row-copy:hover{color:var(--accent);border-color:var(--accent)}.transport{border:1px solid var(--edge);background:var(--panel);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;display:flex}button{font:inherit;color:var(--fg);border:1px solid var(--edge);cursor:pointer;background:#121a15;border-radius:4px;padding:6px 12px}button:hover{border-color:var(--green-dim);color:var(--green)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:2px}button.primary{color:var(--green);border-color:var(--green-dim);min-width:64px}button.ghost{color:var(--muted);background:0 0}button.golive{color:#ff5c5c;letter-spacing:.06em;border-color:#7a2b2b}button.golive:hover{color:#fff;background:#7a2b2b;border-color:#ff5c5c}button.golive:disabled{opacity:.5;cursor:progress}.row-live:hover{color:#ff5c5c;border-color:#ff5c5c}.directory-list li:has(>.server-lives){flex-wrap:wrap}.directory-list li>.server-lives{border-left:2px solid var(--green-dim);flex-direction:column;flex:100%;gap:4px;margin:2px 0 0;padding:0 0 0 12px;list-style:none;display:flex}.server-lives li{justify-content:space-between;align-items:center;gap:8px;min-width:0;display:flex}.server-lives li>.detail{text-overflow:ellipsis;white-space:nowrap;flex:auto;min-width:0;overflow:hidden}.server-lives li>.button{flex:none;padding:2px 10px;font-size:12px}.volume{flex:1;align-items:center;gap:8px;min-width:120px;max-width:220px;margin-left:auto;display:flex}.vol{color:var(--muted);letter-spacing:.08em;font-size:12px}.picker{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.link-form{margin-top:8px}.link-form input{flex:240px;min-width:0}.button{border:1px solid var(--edge);cursor:pointer;color:var(--fg);background:#121a15;border-radius:4px;padding:6px 12px;display:inline-block}.button:hover{border-color:var(--green-dim);color:var(--green)}.button input[type=file]{display:none}#remote-url{font:inherit;min-width:12ch;color:var(--fg);border:1px solid var(--edge);background:#060a07;border-radius:4px;flex:1;padding:6px 10px}.check{color:var(--muted);align-items:center;gap:8px;margin-top:10px;display:flex}.check input{accent-color:var(--green)}.hint{color:var(--muted);margin:0 0 8px;font-size:13px}.hint code{color:var(--accent)}#remote-state[data-status=live]{color:var(--green)}#remote-state[data-status=connecting],#remote-state[data-status=error]{color:var(--warn)}.note{color:var(--warn);border-left:2px solid var(--warn);margin:0;padding-left:8px}.statusbar{color:var(--muted);border-top:1px solid var(--edge);flex-wrap:wrap;gap:14px;padding-top:8px;font-size:12px;display:flex}.statusbar b{color:var(--green);font-weight:700}.statusbar .tv-toggle{color:var(--accent);font:inherit;cursor:pointer;background:0 0;border:0;padding:0;text-decoration:underline}body.tv .statusbar .tv-toggle{color:var(--green)}.statusbar .spacer{flex:1}.statusbar a{color:var(--muted)}.directory{border-top:1px solid var(--line);margin-top:.6rem;padding-top:.6rem}.directory-list{max-height:12rem;margin:0;padding:0;list-style:none;overflow-y:auto}.directory-list li+li{margin-top:.3rem}.directory-list button{border:1px solid var(--line);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:4px;padding:.4rem .5rem}.directory-list button:hover,.directory-list button:focus-visible{border-color:var(--accent);background:#ffffff0a}.directory-list .name{color:var(--accent);display:block}.directory-list .detail{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:.85em;display:block;overflow:hidden}.publish-list{margin:.3rem 0 0;padding:0;list-style:none}.publish-list li{align-items:center;gap:8px;margin-top:.3rem;display:flex}.publish-list li.in-use .slot{color:var(--green)}.publish-list .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width:0}.said{color:var(--accent);margin:.3rem 0 0}.directory-list li.offline .recent-label{opacity:.55}.directory-list li.offline .detail{color:var(--warn)}.share-line{align-items:center;gap:8px;margin:.4rem 0;display:flex}.share-what{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;flex:none;font-size:12px}.share-line input{flex:auto;min-width:0}#share-phone{color:var(--fg);font-size:1.05em}#share-phone b{color:var(--accent)}.admin-table{border-collapse:collapse;width:100%;min-height:5.5em;max-height:14rem;margin:.4rem 0;font-size:.85em;display:block;overflow-y:auto}.admin-table th{text-align:left;opacity:.6;padding:.2rem .4rem .2rem 0;font-weight:400}.admin-table td{white-space:nowrap;text-overflow:ellipsis;max-width:12rem;padding:.2rem .4rem .2rem 0;overflow:hidden}.admin-table td.network-public{color:var(--warning,#e0b341)}.admin-table td.network-private{color:var(--success,#7fd18b)}.admin-table tr.ended{opacity:.45}body.route-directory .player-only{display:none}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>:first-child{flex:auto;min-width:0}.directory-list li>button:first-child{text-align:left;width:100%}.directory-list li>.button,.directory-list li>.ghost{white-space:nowrap;flex:none;align-items:center;display:flex}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}.pager{border-top:1px solid var(--edge);justify-content:space-between;align-items:center;gap:8px;margin-top:6px;padding-top:6px;display:flex}.pager .pager-where{color:var(--muted);font-variant-numeric:tabular-nums}.pager button:disabled{opacity:.35;cursor:default}body.tv{font-size:19px}body.tv .playlist,body.tv #catalogs-entries,body.tv .directory-list,body.tv .admin-table{max-height:none;overflow-y:visible}body.tv #app{max-width:1400px}body.tv .row,body.tv .folder{border-radius:4px;padding:8px 10px}body.tv .row:focus-visible,body.tv .folder:focus-visible{outline:2px solid var(--green);outline-offset:-2px;background:#142019}body.tv button,body.tv .button,body.tv .directory-list button{min-height:2.4em;padding:8px 16px}body.tv .row-copy{min-height:0;padding:0 8px}body.tv .icon{min-width:2.4em;height:2.4em}body.tv .directory-list li+li{margin-top:.6rem}body.tv .server-lives li>.button{padding:6px 14px;font-size:.9em}body.tv input,body.tv .filter{padding:8px 10px;font-size:1em}
|
|
1
|
+
:root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}[hidden]{display:none!important}html,body{background:var(--bg);min-height:100%;color:var(--fg);margin:0;font:14px/1.45 ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}body{background-image:repeating-linear-gradient(#4af68906 0 1px,#0000 1px 3px)}#app{max-width:1100px;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));flex-direction:column;gap:10px;margin:0 auto;display:flex}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.status[data-playing=loading]{color:var(--accent)}.status[data-playing=loading]:before,.row.loading>.name:after{content:"";vertical-align:-.1em;border:2px solid;border-right-color:#0000;border-radius:50%;width:.8em;height:.8em;animation:.8s linear infinite turn;display:inline-block}.status[data-playing=loading]:before{margin-right:6px}.row.loading>.name:after{color:var(--accent);margin-left:8px}@keyframes turn{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.status[data-playing=loading]:before,.row.loading>.name:after{animation-duration:2.4s}}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{border:1px solid var(--edge);background:var(--panel);border-radius:6px;min-width:0;padding:14px 12px 12px;position:relative}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;padding:0 6px;font-size:12px;position:absolute;top:-.72em;left:10px}.split{grid-template-columns:1.3fr 1fr;gap:10px;display:grid}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}.stack{align-content:start;gap:14px;min-width:0;display:grid}.directory-list li.onair{border-bottom:1px solid var(--edge);flex-direction:column;align-items:stretch;gap:6px;padding:6px 0}.directory-list li.onair:last-child{border-bottom:0}.directory-list li.onair>.recent-label{flex:none}.onair-actions{flex-wrap:wrap;align-items:center;gap:6px;display:flex}.icon{border:1px solid var(--edge);min-width:2.2em;height:2.2em;color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;font-size:1em;line-height:1}.icon:hover{color:var(--accent);border-color:var(--accent)}.directory-list li>.button:disabled,.directory-list li>.way-in:disabled{opacity:.4;cursor:not-allowed}.directory-list li>.way-in:disabled:hover{color:var(--muted);border-color:var(--edge)}.directory-list li>.way-in{color:var(--fg);flex:none;align-self:center;padding:0 8px}.directory-list li>.way-in svg{width:18px;height:18px}.heart{color:var(--muted);cursor:pointer;background:0 0;border:1px solid #0000;border-radius:3px;padding:2px 6px;font-size:1.1em;line-height:1}.heart:hover{color:var(--accent);border-color:var(--accent)}.heart[data-on=yes]{color:var(--accent)}.directory-list li>span.server-label{flex-direction:column;flex:1 1 0;gap:2px;min-width:0;padding:4px 2px;display:flex}.directory-list li>.server-label .name{font-weight:600}.directory-list li>.server-label .detail{color:var(--muted);white-space:nowrap}.directory-list li>.server-label .live{color:var(--green)}@media (max-width:720px){.split{grid-template-columns:1fr}.split>.col-a,.split>.col-b{grid-column:auto}}.track-title{color:var(--accent);text-overflow:ellipsis;white-space:nowrap;font-weight:700;overflow:hidden}.track-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.track-live{color:var(--muted);overflow-wrap:anywhere;margin-top:6px;line-height:1.5}.track-live b{color:var(--green);font-weight:700}.track-meta{flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px;margin-bottom:4px;display:flex}.meta-chip{color:var(--muted);border:1px solid var(--edge);letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:100%;padding:0 8px;font-size:11px;line-height:1.7;overflow:hidden}.meta-chip:first-of-type{color:var(--green);border-color:var(--green-dim)}.meta-logo{object-fit:contain;background:#000;border-radius:3px;max-width:64px;height:22px}.meta-logo.meta-poster{max-width:52px;height:72px}.meta-score{flex-wrap:wrap;flex-basis:100%;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:1.4;display:flex}.meta-team{color:var(--fg);text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:38vw;overflow:hidden}.meta-points{font-variant-numeric:tabular-nums;font-size:16px;font-weight:700}.meta-dash{color:var(--muted);padding:0 2px}.meta-team-logo{object-fit:contain;flex:none;width:20px;height:20px}.meta-chip.chip-live{color:var(--fg);border-color:#b3261e}.meta-chip.chip-live:before{content:"";vertical-align:1px;background:#e53935;border-radius:50%;width:7px;height:7px;margin-right:5px;animation:1.4s ease-in-out infinite pulse;display:inline-block}@keyframes pulse{50%{opacity:.35}}@media (prefers-reduced-motion:reduce){.meta-chip.chip-live:before{animation:none}}.track-blurb{color:var(--muted);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin:4px 0 0;font-size:12px;line-height:1.5;display:-webkit-box;overflow:hidden}#video{border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px}.scrub{align-items:center;gap:10px;margin-top:6px;display:flex}.time{color:var(--fg);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}input[type=range]{appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:14px}input[type=range]::-webkit-slider-runnable-track{background:linear-gradient(var(--edge), var(--edge));border:1px solid var(--edge);border-radius:3px;height:6px}input[type=range]::-moz-range-track{background:var(--edge);border-radius:3px;height:6px}input[type=range]::-webkit-slider-thumb{appearance:none;background:var(--green);border-radius:2px;width:10px;height:16px;margin-top:-6px}input[type=range]::-moz-range-thumb{background:var(--green);border:0;border-radius:2px;width:10px;height:16px}input[type=range]:disabled{opacity:.45;cursor:default}#spectrum{border:1px solid var(--edge);background:#060a07;border-radius:4px;width:100%;height:190px;display:block}.meters{align-items:baseline;gap:10px;margin-top:6px;display:flex;overflow:hidden}.glyphs{color:var(--green);letter-spacing:1px;white-space:pre;flex:auto;min-width:0;min-height:1.4em;font-family:ui-monospace,SF Mono,Menlo,Consolas,monospace;overflow:hidden}.levelmeter{color:var(--accent);white-space:pre;font-variant-ligatures:none;text-align:right;flex:none;min-width:15ch;margin-left:auto;font-family:ui-monospace,SF Mono,Menlo,Consolas,monospace}.filter{border:1px solid var(--edge);width:100%;color:var(--fg);font:inherit;background:#0a100c;border-radius:4px;margin-bottom:6px;padding:4px 8px}.filter:focus{border-color:var(--accent);outline:none}.crumbs{color:var(--muted);flex-wrap:wrap;align-items:center;gap:4px;margin-bottom:6px;font-size:12px;display:flex}.crumbs button{font:inherit;color:var(--accent);cursor:pointer;background:0 0;border:0;padding:0 2px}.crumbs button:hover{text-decoration:underline}.crumbs .here{color:var(--fg)}.catalog-tag{color:var(--muted);letter-spacing:.06em;flex:none;font-size:11px}.catalog-live{color:var(--green)}#catalogs-entries{max-height:320px;overflow-y:auto}.folder{cursor:pointer;white-space:nowrap;color:var(--accent);border-radius:3px;gap:8px;padding:2px 6px;display:flex}.folder:hover{background:#142019}.folder .name{text-overflow:ellipsis;flex:1;overflow:hidden}.folder .count{color:var(--muted);flex:none}.playlist{scrollbar-color:var(--green-dim) transparent;max-height:214px;margin:0;padding:0;list-style:none;overflow-y:auto}.row{cursor:pointer;white-space:nowrap;border-radius:3px;gap:8px;padding:2px 6px;display:flex}.row:hover{background:#142019}.row.selected{background:#16241c}.row.selected .name{color:var(--accent)}.row.playing .name{color:var(--green)}.group{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--edge);align-items:center;gap:8px;margin-top:4px;padding:6px 6px 2px;font-size:11px;display:flex}.group:first-child{border-top:0;margin-top:0}.group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.group-remove{border:1px solid var(--edge);color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;line-height:1.4}.group-remove:hover{color:var(--accent);border-color:var(--accent)}.row .n{color:var(--muted);text-align:right;flex:none;width:2.4em}.row .name{text-overflow:ellipsis;flex:1;overflow:hidden}.row .time{color:var(--muted);flex:none}.row-copy{color:var(--muted);cursor:pointer;opacity:.55;background:0 0;border:1px solid #0000;border-radius:3px;flex:none;padding:0 5px;line-height:1.4}.row:hover .row-copy,.row.selected .row-copy{opacity:1}.row-copy svg,.icon svg{vertical-align:-2px;width:14px;height:14px;display:inline-block}.icon{justify-content:center;align-items:center;display:inline-flex}.row-copy:hover{color:var(--accent);border-color:var(--accent)}.transport{border:1px solid var(--edge);background:var(--panel);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;display:flex}button{font:inherit;color:var(--fg);border:1px solid var(--edge);cursor:pointer;background:#121a15;border-radius:4px;padding:6px 12px}button:hover{border-color:var(--green-dim);color:var(--green)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:2px}button.primary{color:var(--green);border-color:var(--green-dim);min-width:64px}button.ghost{color:var(--muted);background:0 0}button.golive{color:#ff5c5c;letter-spacing:.06em;border-color:#7a2b2b}button.golive:hover{color:#fff;background:#7a2b2b;border-color:#ff5c5c}button.golive:disabled{opacity:.5;cursor:progress}.row-live:hover{color:#ff5c5c;border-color:#ff5c5c}.directory-list li:has(>.server-lives){flex-wrap:wrap}.directory-list li>.server-lives{border-left:2px solid var(--green-dim);flex-direction:column;flex:100%;gap:4px;margin:2px 0 0;padding:0 0 0 12px;list-style:none;display:flex}.server-lives li{justify-content:space-between;align-items:center;gap:8px;min-width:0;display:flex}.server-lives li>.detail{text-overflow:ellipsis;white-space:nowrap;flex:auto;min-width:0;overflow:hidden}.server-lives li>.button{flex:none;padding:2px 10px;font-size:12px}.volume{flex:1;align-items:center;gap:8px;min-width:120px;max-width:220px;margin-left:auto;display:flex}.vol{color:var(--muted);letter-spacing:.08em;font-size:12px}.picker{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.link-form{margin-top:8px}.link-form input{flex:240px;min-width:0}.button{border:1px solid var(--edge);cursor:pointer;color:var(--fg);background:#121a15;border-radius:4px;padding:6px 12px;display:inline-block}.button:hover{border-color:var(--green-dim);color:var(--green)}.button input[type=file]{display:none}#remote-url{font:inherit;min-width:12ch;color:var(--fg);border:1px solid var(--edge);background:#060a07;border-radius:4px;flex:1;padding:6px 10px}.check{color:var(--muted);align-items:center;gap:8px;margin-top:10px;display:flex}.check input{accent-color:var(--green)}.hint{color:var(--muted);margin:0 0 8px;font-size:13px}.hint code{color:var(--accent)}#remote-state[data-status=live]{color:var(--green)}#remote-state[data-status=connecting],#remote-state[data-status=error]{color:var(--warn)}.note{color:var(--warn);border-left:2px solid var(--warn);margin:0;padding-left:8px}.statusbar{color:var(--muted);border-top:1px solid var(--edge);flex-wrap:wrap;gap:14px;padding-top:8px;font-size:12px;display:flex}.statusbar b{color:var(--green);font-weight:700}.statusbar .tv-toggle{color:var(--accent);font:inherit;cursor:pointer;background:0 0;border:0;padding:0;text-decoration:underline}body.tv .statusbar .tv-toggle{color:var(--green)}.statusbar .spacer{flex:1}.statusbar a{color:var(--muted)}.directory{border-top:1px solid var(--line);margin-top:.6rem;padding-top:.6rem}.directory-list{max-height:12rem;margin:0;padding:0;list-style:none;overflow-y:auto}.directory-list li+li{margin-top:.3rem}.directory-list button{border:1px solid var(--line);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:4px;padding:.4rem .5rem}.directory-list button:hover,.directory-list button:focus-visible{border-color:var(--accent);background:#ffffff0a}.directory-list .name{color:var(--accent);display:block}.directory-list .detail{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:.85em;display:block;overflow:hidden}.publish-list{margin:.3rem 0 0;padding:0;list-style:none}.publish-list li{align-items:center;gap:8px;margin-top:.3rem;display:flex}.publish-list li.in-use .slot{color:var(--green)}.publish-list .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width:0}.said{color:var(--accent);margin:.3rem 0 0}.directory-list li.offline .recent-label{opacity:.55}.directory-list li.offline .detail{color:var(--warn)}.share-line{align-items:center;gap:8px;margin:.4rem 0;display:flex}.share-what{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;flex:none;font-size:12px}.share-line input{flex:auto;min-width:0}#share-phone{color:var(--fg);font-size:1.05em}#share-phone b{color:var(--accent)}.admin-table{border-collapse:collapse;width:100%;min-height:5.5em;max-height:14rem;margin:.4rem 0;font-size:.85em;display:block;overflow-y:auto}.admin-table th{text-align:left;opacity:.6;padding:.2rem .4rem .2rem 0;font-weight:400}.admin-table td{white-space:nowrap;text-overflow:ellipsis;max-width:12rem;padding:.2rem .4rem .2rem 0;overflow:hidden}.admin-table td.network-public{color:var(--warning,#e0b341)}.admin-table td.network-private{color:var(--success,#7fd18b)}.admin-table tr.ended{opacity:.45}body.route-directory .player-only{display:none}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>:first-child{flex:auto;min-width:0}.directory-list li>button:first-child{text-align:left;width:100%}.directory-list li>.button,.directory-list li>.ghost{white-space:nowrap;flex:none;align-items:center;display:flex}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}.pager{border-top:1px solid var(--edge);justify-content:space-between;align-items:center;gap:8px;margin-top:6px;padding-top:6px;display:flex}.pager .pager-where{color:var(--muted);font-variant-numeric:tabular-nums}.pager button:disabled{opacity:.35;cursor:default}body.tv{font-size:19px}body.tv .playlist,body.tv #catalogs-entries,body.tv .directory-list,body.tv .admin-table{max-height:none;overflow-y:visible}body.tv #app{max-width:1400px}body.tv .row,body.tv .folder{border-radius:4px;padding:5px 10px}body.tv .row:focus-visible,body.tv .folder:focus-visible{outline:2px solid var(--green);outline-offset:-2px;background:#142019}body.tv button,body.tv .button,body.tv .directory-list button{min-height:2.4em;padding:8px 16px}body.tv .row-copy{min-height:0;padding:0 8px}body.tv .icon{min-width:2.4em;height:2.4em}body.tv .directory-list li+li{margin-top:.6rem}body.tv .server-lives li>.button{padding:6px 14px;font-size:.9em}body.tv input,body.tv .filter{padding:8px 10px;font-size:1em}
|