nixamp 0.23.0 → 0.23.2

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/server.ts CHANGED
@@ -53,7 +53,8 @@ import { createHash as sha } from "node:crypto";
53
53
  import { playJingle } from "./jingle.ts";
54
54
  import { stateDir } from "./daemon.ts";
55
55
  import { readSession } from "./session.ts";
56
- import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
56
+ import { Directory, ENDED_TTL_MS, parseAnnouncement, type LineupEntry, type Listing } from "./directory.ts";
57
+ import { ArtCache, coverArtOf, stillFrom } from "./art.ts";
57
58
  import { PartyLine, telnyxSms } from "./partyline.ts";
58
59
  import { HlsPackagers, segmentType, withKey } from "./hls.ts";
59
60
  import { CompressionService } from "./compression/service.ts";
@@ -114,6 +115,7 @@ import {
114
115
  scopeOf,
115
116
  shareLink,
116
117
  audioLink,
118
+ KEY_QUERY,
117
119
  } from "./share.ts";
118
120
  import { extname, join, normalize, resolve, sep } from "node:path";
119
121
  import { fileURLToPath } from "node:url";
@@ -1059,6 +1061,9 @@ export interface KnownSource {
1059
1061
  /** A list of entries to play in turn, and which one was on. */
1060
1062
  playlist?: string[];
1061
1063
  playlistAt?: number;
1064
+ /** A picture and a line about it, from the site or the list that named it. */
1065
+ art?: string;
1066
+ about?: string;
1062
1067
  }
1063
1068
 
1064
1069
  /** How many times a source that answers nothing is asked, and how far apart. */
@@ -1142,8 +1147,12 @@ export async function pullChannel(
1142
1147
  // the timestamps a recording cut mid-stream does not carry. Ahead of the
1143
1148
  // caller's own input arguments, which are headers for the address itself.
1144
1149
  const opening = [...transportInputArgs(source, codecs.container), ...input];
1150
+ // A bare file is named by its file name until the file says better: an
1151
+ // MP3's tags name the episode and the show, and "Inspiring Founders" is a
1152
+ // card; "ep-0412-final.mp3" is not.
1153
+ const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name;
1145
1154
  const channel = channels.pull(
1146
- id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "",
1155
+ id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "",
1147
1156
  { live, position: known.position ?? 0, ...(playlist ? { playlist } : {}) },
1148
1157
  // Known before the first dial: whether the source is a transport stream
1149
1158
  // decides whether it can be read here for a source-boundary relay.
@@ -1151,6 +1160,28 @@ export async function pullChannel(
1151
1160
  );
1152
1161
  if (channel && !assumed) channel.info.codecs = codecs;
1153
1162
  if (channel && playlist && known.playlistAt !== undefined) channel.info.playlistAt = known.playlistAt;
1163
+ if (channel) {
1164
+ // Its picture from wherever it came with one, and a line about it: what
1165
+ // was given, else the show and the album the file's own tags name.
1166
+ if (known.art && /^https?:\/\//i.test(known.art)) channel.info.art = known.art;
1167
+ const byTags = [codecs.tags?.artist, codecs.tags?.album].filter((one): one is string => Boolean(one)).join(" · ");
1168
+ const about = (known.about ?? byTags).trim().slice(0, 200);
1169
+ if (about !== "") channel.info.about = about;
1170
+ // A channel of sound put back from what an older nixamp wrote down was
1171
+ // never asked whether its file carries a sleeve. Asked now, once, after
1172
+ // it is on: reading the head of an MP3 on a CDN costs nothing and
1173
+ // opens no second connection a panel would count (that is a film's
1174
+ // trap, and a film has a picture of its own to take a frame of).
1175
+ if (kind === "audio" && !channel.info.art && known.codecs && codecs.cover === undefined) {
1176
+ void codecsOf(tools, source, input).then((later) => {
1177
+ if (channel.info.codecs && later.cover) channel.info.codecs.cover = true;
1178
+ if (!channel.info.about) {
1179
+ const line = [later.tags?.artist, later.tags?.album].filter((one): one is string => Boolean(one)).join(" · ");
1180
+ if (line !== "") channel.info.about = line;
1181
+ }
1182
+ });
1183
+ }
1184
+ }
1154
1185
  // What comes out, as opposed to what went in. An H.265 source copied
1155
1186
  // through stays H.265; one re-encoded arrives as H.264, and a packager
1156
1187
  // told otherwise would cut fMP4 segments for a stream that did not need
@@ -1161,6 +1192,53 @@ export async function pullChannel(
1161
1192
  return channel;
1162
1193
  }
1163
1194
 
1195
+ /** Whether a name is only a file's: something.mp3, with nothing said about it. */
1196
+ export function looksLikeFileName(name: string): boolean {
1197
+ return /\.[a-z0-9]{2,5}$/i.test(name.trim());
1198
+ }
1199
+
1200
+ /**
1201
+ * Where a channel's picture is, for the page and the directory.
1202
+ *
1203
+ * The site's own thumbnail as it is; else this server's art route, when
1204
+ * the channel has a picture to read one from -- a sleeve in the file, or a
1205
+ * moving picture to take a frame of. "" for a channel of sound with no
1206
+ * sleeve, which has no picture to be had.
1207
+ */
1208
+ export function artOf(info: Pick<ChannelInfo, "id" | "art" | "kind" | "codecs">): string {
1209
+ if (info.art) return info.art;
1210
+ return info.codecs?.cover === true || info.kind === "video" ? `/api/channels/${encodeURIComponent(info.id)}/art` : "";
1211
+ }
1212
+
1213
+ /**
1214
+ * The same, as a crawler reaches it: an absolute address on this server
1215
+ * with the listen key on it, since a preview is fetched by nobody who
1216
+ * holds the cookie. The listen link is what the directory publishes
1217
+ * already, so the key on the picture gives away nothing new.
1218
+ */
1219
+ export function absoluteArtOf(listenLink: string, info: Pick<ChannelInfo, "id" | "art" | "kind" | "codecs">): string {
1220
+ const art = artOf(info);
1221
+ if (art === "" || /^https?:\/\//i.test(art)) return art;
1222
+ try {
1223
+ const link = new URL(listenLink);
1224
+ const key = /\/(?:view|admin)\/([^/?#]+)$/.exec(link.pathname)?.[1] ?? "";
1225
+ return `${link.origin}${art}${key ? `?${KEY_QUERY}=${encodeURIComponent(key)}` : ""}`;
1226
+ } catch {
1227
+ return "";
1228
+ }
1229
+ }
1230
+
1231
+ /** A channel as the directory's lineup wants it: id, name, kind, picture, line. */
1232
+ export function lineupEntry(listenLink: string, info: ChannelInfo): LineupEntry {
1233
+ return {
1234
+ id: info.id,
1235
+ name: info.name,
1236
+ kind: info.kind ?? "audio",
1237
+ art: absoluteArtOf(listenLink, info),
1238
+ about: info.about ?? "",
1239
+ };
1240
+ }
1241
+
1164
1242
  /** What a link resolves to, kept so a download can be named without asking twice. */
1165
1243
  const links = new Map<string, ResolvedLink>();
1166
1244
 
@@ -1204,6 +1282,9 @@ function shownLink(channelId: string, link: ResolvedLink, channel?: ChannelInfo)
1204
1282
  download: !live && link.extractor !== "direct",
1205
1283
  // How many things a list holds, for the page to say.
1206
1284
  entries: link.playlist?.length ?? 0,
1285
+ // Its picture and its line, for the meta line and the lock screen.
1286
+ art: channel ? artOf(channel) : link.thumbnail,
1287
+ about: channel?.about ?? link.about,
1207
1288
  };
1208
1289
  }
1209
1290
 
@@ -1361,13 +1442,31 @@ function eventDocument(shell: string, event: Awaited<ReturnType<LiveEvents["get"
1361
1442
  * query is not used: a page that titled itself with whatever the address said
1362
1443
  * would be a preview anyone could put words in.
1363
1444
  */
1445
+ /** What a join link opens, as the shell's head says it: a name, where it is, and a picture and a line when it has them. */
1446
+ export interface JoinSubject {
1447
+ title: string;
1448
+ where: string;
1449
+ /** An absolute picture address a crawler can fetch, or "". */
1450
+ image?: string;
1451
+ /** A line about it, or "". */
1452
+ about?: string;
1453
+ /** Whether the picture is a frame of a moving picture (wide) or a sleeve (square). */
1454
+ kind?: "audio" | "video";
1455
+ }
1456
+
1364
1457
  export function joinSubject(
1365
1458
  url: URL,
1366
- options: Pick<HandlerOptions, "directory" | "channels" | "serverName">,
1367
- ): { title: string; where: string } | null {
1459
+ options: Pick<HandlerOptions, "directory" | "channels" | "serverName" | "live">,
1460
+ ): JoinSubject | null {
1368
1461
  const play = url.searchParams.get("play") ?? "";
1369
1462
  const wanted = play.startsWith("channel:") ? play.slice("channel:".length) : "";
1370
1463
  const link = url.searchParams.get("url") ?? "";
1464
+ const withArt = (subject: JoinSubject, entry: Pick<LineupEntry, "art" | "about" | "kind"> | undefined): JoinSubject => ({
1465
+ ...subject,
1466
+ ...(entry?.art ? { image: entry.art } : {}),
1467
+ ...(entry?.about ? { about: entry.about } : {}),
1468
+ ...(entry ? { kind: entry.kind } : {}),
1469
+ });
1371
1470
  if (options.directory) {
1372
1471
  if (link === "") return null;
1373
1472
  const listings = options.directory.list();
@@ -1379,7 +1478,14 @@ export function joinSubject(
1379
1478
  }
1380
1479
  });
1381
1480
  if (!listing) return null;
1382
- if (wanted !== "" && listing.channels.includes(wanted)) return { title: wanted, where: listing.name };
1481
+ if (wanted !== "") {
1482
+ // A join link names a channel by id; the directory row names it by
1483
+ // name; the lineup knows both. An older server's listing has names
1484
+ // alone, and is matched on those as before.
1485
+ const entry = listing.lineup.find((one) => one.id === wanted) ?? listing.lineup.find((one) => one.name === wanted);
1486
+ if (entry) return withArt({ title: entry.name, where: listing.name }, entry);
1487
+ if (listing.channels.includes(wanted)) return { title: wanted, where: listing.name };
1488
+ }
1383
1489
  return { title: listing.name, where: "" };
1384
1490
  }
1385
1491
  // A link to somewhere else is that server's to name.
@@ -1387,19 +1493,38 @@ export function joinSubject(
1387
1493
  const name = options.serverName ?? "";
1388
1494
  if (wanted !== "") {
1389
1495
  const channel = options.channels?.list().find((one) => one.id === wanted || one.name === wanted);
1390
- return channel ? { title: channel.name, where: name } : null;
1496
+ if (!channel) return null;
1497
+ // Its picture as a crawler reaches it: absolute, with the listen key,
1498
+ // which the listing this server publishes carries anyway.
1499
+ const listen = options.live?.status().url ?? "";
1500
+ const entry = listen ? lineupEntry(listen, channel) : undefined;
1501
+ return withArt({ title: channel.name, where: name }, entry);
1391
1502
  }
1392
1503
  return name === "" ? null : { title: name, where: "" };
1393
1504
  }
1394
1505
 
1395
- /** The shell, titled for what a join link opens. */
1396
- export function joinDocument(shell: string, subject: { title: string; where: string }, site: string): string {
1506
+ /** The shell, titled for what a join link opens, with its picture on the card. */
1507
+ export function joinDocument(shell: string, subject: JoinSubject, site: string): string {
1397
1508
  const brand = brandOf(shell, site);
1398
1509
  const shellTitle = /<title>([^<]*)<\/title>/i.exec(shell)?.[1] ?? "";
1399
1510
  const title = `${subject.title} — ${brand}`;
1400
- const description = subject.where === ""
1511
+ const where = subject.where === ""
1401
1512
  ? `${subject.title} is live on ${brand}. Tune in free, no account needed.`
1402
1513
  : `${subject.title} is live on ${subject.where}. Tune in free on ${brand}, no account needed.`;
1514
+ // What it is about comes first, then where it is: a card has two lines.
1515
+ const description = subject.about ? `${subject.about} — ${where}` : where;
1516
+ const image = subject.image ?? "";
1517
+ // A frame of a picture is wide and fills the card; a sleeve is square and
1518
+ // sits beside the words. Twitter has a card for each and picks neither
1519
+ // without being told; everything else reads og:image and decides itself.
1520
+ const card = image === "" ? "" : [
1521
+ `<meta property="og:image" content="${htmlText(image)}" />`,
1522
+ `<meta property="og:image:alt" content="${htmlText(subject.title)}" />`,
1523
+ `<meta name="twitter:card" content="${subject.kind === "video" ? "summary_large_image" : "summary"}" />`,
1524
+ `<meta name="twitter:title" content="${htmlText(subject.title)}" />`,
1525
+ `<meta name="twitter:description" content="${htmlText(description)}" />`,
1526
+ `<meta name="twitter:image" content="${htmlText(image)}" />`,
1527
+ ].map((line) => `${line}\n `).join("");
1403
1528
  return shell
1404
1529
  .replace(/<title>.*?<\/title>/s, `<title>${htmlText(title)}</title>`)
1405
1530
  .replace(/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${htmlText(description)}" />`)
@@ -1407,7 +1532,7 @@ export function joinDocument(shell: string, subject: { title: string; where: str
1407
1532
  .replace(/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${htmlText(description)}" />`)
1408
1533
  // What the tab is called once the page takes over, so it does not stack
1409
1534
  // the channel on a title that already names the channel.
1410
- .replace("</head>", `<meta name="nixamp-shell-title" content="${htmlText(shellTitle)}" />\n </head>`);
1535
+ .replace("</head>", `${card}<meta name="nixamp-shell-title" content="${htmlText(shellTitle)}" />\n </head>`);
1411
1536
  }
1412
1537
 
1413
1538
  function json(response: ServerResponse, code: number, body: unknown): void {
@@ -1472,6 +1597,8 @@ export interface HandlerOptions {
1472
1597
  ytdlp?: string[] | null;
1473
1598
  /** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
1474
1599
  hls?: HlsPackagers;
1600
+ /** The pictures of channels already taken, so a card unfurled by five crawlers costs one ffmpeg. */
1601
+ art?: ArtCache;
1475
1602
  /** Captions for a channel: its sound, as lines, as they are heard. Needs ffmpeg and a sign-in. */
1476
1603
  captions?: Captions;
1477
1604
  /** Lossless relay compression, its policies, diagnostics and static representations. */
@@ -3194,6 +3321,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3194
3321
  ...(one.playlist ? { entries: one.playlist.length, entry: one.playlistAt ?? 0 } : {}),
3195
3322
  // The member who put it on, when one did: theirs to take off.
3196
3323
  startedBy: one.startedBy ?? "",
3324
+ // A picture of it, and a line about it, where there is one.
3325
+ art: artOf(one),
3326
+ about: one.about ?? "",
3197
3327
  })),
3198
3328
  // Anything re-streamed into this server is a live stream too, and was
3199
3329
  // sitting in the middle of the playlist among the files -- which is
@@ -3318,7 +3448,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3318
3448
  json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
3319
3449
  return;
3320
3450
  }
3321
- const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source);
3451
+ const started = await pullChannel(
3452
+ options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source, [], "",
3453
+ // The logo the list gave it is its picture.
3454
+ entry.logo ? { art: entry.logo, about: entry.group } : {},
3455
+ );
3322
3456
  if (!started) {
3323
3457
  json(response, 409, { error: "that channel is already starting" });
3324
3458
  return;
@@ -3353,7 +3487,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3353
3487
  }
3354
3488
  }
3355
3489
  if (!options.channels.has(channelId)) {
3356
- const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source);
3490
+ const started = await pullChannel(
3491
+ options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source, [], "",
3492
+ // The logo the list gave it is its picture.
3493
+ entry.logo ? { art: entry.logo, about: entry.group } : {},
3494
+ );
3357
3495
  if (!started) {
3358
3496
  json(response, 409, { error: "that channel is already starting" });
3359
3497
  return;
@@ -3547,7 +3685,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3547
3685
  const started = await pullChannel(
3548
3686
  options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media,
3549
3687
  inputArgsFor(resolved.headers), resolved.audio,
3550
- resolved.playlist ? { playlist: resolved.playlist } : {},
3688
+ {
3689
+ ...(resolved.playlist ? { playlist: resolved.playlist } : {}),
3690
+ ...(resolved.thumbnail ? { art: resolved.thumbnail } : {}),
3691
+ ...(resolved.about ? { about: resolved.about } : {}),
3692
+ },
3551
3693
  );
3552
3694
  if (!started) {
3553
3695
  json(response, 409, { error: "that link is already starting" });
@@ -3753,6 +3895,49 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3753
3895
  const compression = compressionCtx();
3754
3896
  if (compression && (await handleChannelCompression(request, response, compression, id, action, file))) return;
3755
3897
 
3898
+ // A picture of the channel: the sleeve in the file, or one frame of
3899
+ // what it is sending, as a JPEG. This is what a link preview shows
3900
+ // and what the lock screen shows, and a crawler fetches it with the
3901
+ // listen key on the address, since it holds no cookie.
3902
+ if (action === "art" && (request.method === "GET" || request.method === "HEAD")) {
3903
+ const info = channels.info(id);
3904
+ if (!info || options.carries === false) {
3905
+ json(response, 404, { error: "nothing is playing on that channel" });
3906
+ return;
3907
+ }
3908
+ // The site's own picture is where it is; sent there, so one address
3909
+ // per channel serves whichever kind it has.
3910
+ if (info.art && /^https?:\/\//i.test(info.art)) {
3911
+ response.writeHead(302, { ...CORS, location: info.art, "cache-control": "public, max-age=300" });
3912
+ response.end();
3913
+ return;
3914
+ }
3915
+ const ffmpeg = options.ffmpeg ?? ["ffmpeg"];
3916
+ const cover = info.codecs?.cover === true && Boolean(info.source);
3917
+ const cache = options.art ?? new ArtCache();
3918
+ const bytes = cover
3919
+ ? await cache.get(`${id}:cover:${info.startedAt}`, true, () => {
3920
+ // With the headers its site wanted, when it came from a link.
3921
+ const link = [...links.values()].find((one) => one.media === info.source);
3922
+ return coverArtOf(ffmpeg, info.source as string, link ? inputArgsFor(link.headers) : []);
3923
+ })
3924
+ : info.kind === "video"
3925
+ ? await cache.get(`${id}:still`, false, () => stillFrom(ffmpeg, channels.opening(id)))
3926
+ : null;
3927
+ if (!bytes) {
3928
+ json(response, 404, { error: "that channel has no picture yet" });
3929
+ return;
3930
+ }
3931
+ response.writeHead(200, {
3932
+ ...CORS,
3933
+ "content-type": "image/jpeg",
3934
+ "content-length": bytes.byteLength,
3935
+ "cache-control": cover ? "public, max-age=3600" : "public, max-age=60",
3936
+ });
3937
+ response.end(request.method === "HEAD" ? undefined : bytes);
3938
+ return;
3939
+ }
3940
+
3756
3941
  // The same channel as HLS: a playlist of short files, which is what
3757
3942
  // Safari on an iPhone plays live -- it will not take the endless MP4
3758
3943
  // below, and spun on it a few times before giving up. Packaged on
@@ -5088,11 +5273,16 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5088
5273
  key !== null && scopeOf(keyFrom(request, new URL(request.url ?? "/", "http://localhost")), key, null) === "control",
5089
5274
  });
5090
5275
 
5276
+ // The pictures of channels, taken once each and kept while the channel is on.
5277
+ const art = new ArtCache();
5091
5278
  const channels = new Channels({
5092
5279
  ffmpeg: tools.ffmpeg,
5093
5280
  onStart: (info) =>
5094
5281
  console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
5095
- onEnd: (info) => console.log(` "${info.id}" stopped.`),
5282
+ onEnd: (info) => {
5283
+ console.log(` "${info.id}" stopped.`);
5284
+ art.forget(info.id);
5285
+ },
5096
5286
  });
5097
5287
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
5098
5288
  // channel's fragments into short files while somebody is asking for them.
@@ -5136,6 +5326,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5136
5326
  ...(one.position !== undefined ? { position: one.position } : {}),
5137
5327
  ...(one.live !== undefined ? { live: one.live } : {}),
5138
5328
  ...(one.playlist ? { playlist: one.playlist, playlistAt: one.playlistAt ?? 0 } : {}),
5329
+ ...(one.art ? { art: one.art } : {}),
5330
+ ...(one.about ? { about: one.about } : {}),
5139
5331
  }).then((channel) => {
5140
5332
  if (!channel) console.log(` "${one.id}" is already on.`);
5141
5333
  });
@@ -5605,6 +5797,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5605
5797
  carries: tools.carries !== false,
5606
5798
  cookies: cookiesFile(),
5607
5799
  hls,
5800
+ art,
5608
5801
  ...(captions ? { captions } : {}),
5609
5802
  compression,
5610
5803
  enricher,
@@ -5952,6 +6145,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5952
6145
  // live: CNN" is one somebody can decide about; "5717 tracks" was not.
5953
6146
  playing: () => engine.snapshot(false).playing,
5954
6147
  channels: () => channels.list().map((one) => one.name),
6148
+ // The same with what a link preview needs: the id a join link names
6149
+ // a channel by, a picture, a line.
6150
+ lineup: () => channels.list().map((one) => lineupEntry(listen, one)),
5955
6151
  // From `nixamp login`. The directory will not list a stream it cannot
5956
6152
  // attribute to somebody, because a listing is now a phone code that
5957
6153
  // costs money to answer.
@@ -1 +1 @@
1
- import{t as e}from"./index-BIL7sVli.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-B55UODw_.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};
@@ -0,0 +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}.welcome-title{color:var(--green);margin:4px 0 8px;font-size:22px;font-weight:600;line-height:1.25}.welcome-lead{max-width:70ch;color:var(--fg);margin:0 0 14px}.welcome-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-bottom:14px;display:grid}.welcome-grid h2{letter-spacing:.06em;text-transform:uppercase;color:var(--green-dim);margin:0 0 4px;font-size:13px}.welcome-grid p{color:var(--muted);margin:0;font-size:13px}.welcome-grid code,.welcome-install{color:var(--accent)}.welcome-actions{gap:10px}.welcome-install{border:1px dashed var(--edge);overflow-wrap:anywhere;border-radius:4px;padding:6px 10px;font-size:12px}.welcome .button.primary{color:var(--green);border-color:var(--green-dim)}@media (max-width:720px){.welcome-grid{grid-template-columns:1fr}.welcome-title{font-size:19px}}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;align-items:center;gap:6px;font-weight:700;display:inline-flex}.brand-mark{flex:none;width:1.15em;height:1.15em}.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{background:linear-gradient(var(--panel), var(--panel)) 0 9px / 100% calc(100% - 9px) no-repeat;min-width:0;padding:22px 12px 12px;position:relative}.panel:after{content:"";border:1px solid var(--edge);pointer-events:none;border-radius:6px;position:absolute;inset:9px 0 0}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;z-index:1;padding:0 6px;font-size:12px;line-height:18px;position:absolute;top:0;left:10px}.panel-tools{background:var(--panel);z-index:1;align-items:center;gap:2px;height:18px;padding:0 4px;display:flex;position:absolute;top:0;right:8px}.panel-tools button{font:inherit;border:1px solid var(--edge);height:16px;color:var(--muted);cursor:pointer;background:#121a15;border-radius:3px;padding:0 5px;font-size:11px;line-height:1}.panel-tools button:hover{color:var(--fg);border-color:var(--green-dim)}.panel-tools .grip{cursor:grab}.panel[data-collapsed]{padding:18px 12px 0}.panel[data-collapsed]>:not(.panel-tools),.panel[data-closed]{display:none!important}.panel.dragging{opacity:.45}.panel.drop-before{box-shadow:0 -4px 0 0 var(--accent)}.panel.drop-after{box-shadow:0 4px 0 0 var(--accent)}.link-note{color:var(--warn);margin:6px 0 0}.log-list{max-height:220px;margin:0;padding:0;font-size:12px;list-style:none;overflow-y:auto}.log-list li{border-bottom:1px dotted var(--edge);align-items:baseline;gap:8px;padding:2px 0;display:flex}.log-list time{color:var(--muted);flex:none}.log-list span{overflow-wrap:anywhere;min-width:0}.trollbox-list{max-height:320px;margin:0 0 8px;padding:0;font-size:13px;list-style:none;overflow-y:auto}.trollbox-list li{border-bottom:1px dotted var(--edge);align-items:baseline;gap:6px;padding:2px 0;display:flex}.trollbox-list .when{color:var(--muted);flex:none;font-size:11px}.trollbox-list .who{color:var(--accent);flex:none}.trollbox-list .who:after{content:":"}.trollbox-list .line{overflow-wrap:anywhere;flex:auto;min-width:0}.trollbox-list .icon-btn{padding:0 4px;font-size:11px}.button.mic{min-width:44px}.button.mic[aria-pressed=true]{color:#ff6b6b;border-color:#ff6b6b;animation:1.2s ease-in-out infinite listening}.button.mic[disabled]{opacity:.6}@keyframes listening{50%{opacity:.55}}.transcript-list{max-height:320px;margin:0;padding:0;font-size:13px;list-style:none;overflow-y:auto}.transcript-list li{border-bottom:1px dotted var(--edge);align-items:baseline;gap:6px;padding:2px 0;display:flex}.transcript-list .when{color:var(--muted);flex:none;font-size:11px}.transcript-list .line{overflow-wrap:anywhere;flex:auto;min-width:0}.transcript-list li.now .line{color:var(--accent)}.transcript-switch{color:var(--muted);align-items:center;gap:6px;margin:0 0 6px;font-size:12px;display:inline-flex}.subtitle{z-index:2;pointer-events:none;text-align:center;color:#fff;text-shadow:0 1px 2px #000;background:#000000a8;border-radius:4px;padding:4px 10px;font-size:clamp(14px,2.4vw,22px);line-height:1.3;position:absolute;left:12px;right:12px}body.tv .subtitle{font-size:28px}.panels-list li{align-items:center;gap:8px;padding:3px 0;display:flex}.panels-list li label{cursor:pointer;flex:auto;align-items:center;gap:8px;display:flex}.panels-list li .name{flex:auto}.panels-list li.off .name{color:var(--muted)}.panels-list li .detail{color:var(--muted);white-space:nowrap;font-size:12px}.split{columns:2;column-gap:10px}.split>*{break-inside:avoid;width:100%;margin-bottom:14px;display:block}@supports (display:grid-lanes){.split{display:grid-lanes;columns:auto;grid-template-columns:1.3fr 1fr;gap:14px 10px}.split>*{margin-bottom:0}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}}.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{columns:1}@supports (display:grid-lanes){.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}.live-art{object-fit:cover;vertical-align:middle;background:#000;border-radius:3px;flex:0 0 28px;width:28px;height:28px;margin-right:6px}.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.share{color:var(--accent);border-color:var(--edge);letter-spacing:.04em}button.share:hover{background:#16241c}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}.link-form select{min-width:0;font:inherit;color:inherit;background:#0b120e;border:1px solid #2a3a30;border-radius:4px;flex:0 180px;padding:6px 8px}.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}.embed{aspect-ratio:16/9;border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px;overflow:hidden}.embed iframe{border:0;width:100%;height:100%;display:block}.way-in{color:var(--fg);padding:0 8px}.way-in svg{width:18px;height:18px}.way-in:disabled{opacity:.4;cursor:not-allowed}.way-in:disabled:hover{color:var(--muted);border-color:var(--edge)}.way-in-here{align-items:center;gap:4px;display:inline-flex}