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/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(source: string, encode: string[], paced = true, stall = STALL, input: string[] = [], audio = ""): void {
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.filter(
771
- (one): one is RememberedChannel =>
772
- typeof one === "object" && one !== null &&
773
- typeof (one as RememberedChannel).id === "string" &&
774
- typeof (one as RememberedChannel).name === "string" &&
775
- typeof (one as RememberedChannel).source === "string",
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 [picture, sound] = await Promise.all([
996
- codecsOf(tools, source, input),
997
- audio ? codecsOf(tools, audio, input) : Promise.resolve(null),
998
- ]);
999
- const codecs = sound ? { ...picture, audio: sound.audio } : picture;
1000
- const kind = codecs.video === "" ? "audio" : "video";
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
- return channels.pull(id, name, source, encode, kind, true, undefined, input, kind === "video" ? audio : "");
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
- if (options.rememberChannels) {
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 && options.rememberChannels) {
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
- if (options.rememberChannels) {
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
- if (options.rememberChannels) {
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
- console.log(` Putting "${one.id}" (${one.name}) back on the air.`);
4152
- void pullChannel(channels, tools.ffprobe, one.id, one.name, one.source).then((channel) => {
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-C8bIVJ3F.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
+ 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}