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/README.md +13 -0
- package/dist/art.d.ts +41 -0
- package/dist/art.js +154 -0
- package/dist/audio.d.ts +18 -0
- package/dist/audio.js +27 -1
- package/dist/channels.d.ts +14 -0
- package/dist/channels.js +12 -0
- package/dist/directory.d.ts +22 -0
- package/dist/directory.js +27 -0
- package/dist/links.d.ts +17 -0
- package/dist/links.js +52 -0
- package/dist/publish.d.ts +3 -1
- package/dist/publish.js +1 -0
- package/dist/server.d.ts +40 -9
- package/dist/server.js +190 -22
- package/package.json +1 -1
- package/src/art.ts +151 -0
- package/src/audio.ts +32 -2
- package/src/channels.ts +21 -2
- package/src/directory.ts +47 -0
- package/src/links.ts +64 -0
- package/src/publish.ts +4 -1
- package/src/server.ts +210 -14
- package/web/dist/assets/{hls-3VKVEQE3-DOUPX_xZ.js → hls-3VKVEQE3-C6TjK2l6.js} +1 -1
- package/web/dist/assets/index-46pwGn5-.css +1 -0
- package/web/dist/assets/index-B55UODw_.js +1 -0
- package/web/dist/assets/{mpegts-XL4bjo7s.js → mpegts-BRcyaIhm.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-C9gfFFrM.js → mpegts-LO6RVLD6-DI1tgjsu.js} +1 -1
- package/web/dist/favicon.png +0 -0
- package/web/dist/hero.png +0 -0
- package/web/dist/index.html +5 -4
- package/web/dist/logo.png +0 -0
- package/web/dist/sw.js +9 -6
- package/web/dist/assets/index-BIL7sVli.js +0 -1
- package/web/dist/assets/index-D3Is8ykj.css +0 -1
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,
|
|
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
|
-
):
|
|
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 !== ""
|
|
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
|
-
|
|
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:
|
|
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
|
|
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>",
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
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-
|
|
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}
|