nixamp 0.20.2 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.d.ts +3 -0
- package/dist/server.js +89 -0
- package/dist/trollbox.d.ts +53 -0
- package/dist/trollbox.js +0 -0
- package/package.json +1 -1
- package/src/server.ts +87 -0
- package/src/trollbox.ts +0 -0
- package/web/dist/assets/{hls-3VKVEQE3-CDa_Z7V7.js → hls-3VKVEQE3-BJsIk1Jh.js} +1 -1
- package/web/dist/assets/index-8WMlGG4r.css +1 -0
- package/web/dist/assets/index-bWLeXdTo.js +1 -0
- package/web/dist/assets/{mpegts-Chv8DKWK.js → mpegts-817UUWFZ.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DX-iRcF3.js → mpegts-LO6RVLD6-BxolOtwL.js} +1 -1
- package/web/dist/index.html +16 -2
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-2L_SbGuH.js +0 -1
- package/web/dist/assets/index-Diqt1Ova.css +0 -1
package/dist/server.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { LiveEvents } from "./live-events.ts";
|
|
|
27
27
|
import { Tickets } from "./tickets.ts";
|
|
28
28
|
import { Layouts } from "./layouts.ts";
|
|
29
29
|
import { Rooms } from "./rooms.ts";
|
|
30
|
+
import { Trollbox } from "./trollbox.ts";
|
|
30
31
|
import { type Codecs } from "./audio.ts";
|
|
31
32
|
import { type Tools, type Track } from "./audio.ts";
|
|
32
33
|
import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
|
|
@@ -655,6 +656,8 @@ export interface HandlerOptions {
|
|
|
655
656
|
layouts?: Layouts;
|
|
656
657
|
/** Persistent participation state that must not be coupled to live audio. */
|
|
657
658
|
rooms?: Rooms;
|
|
659
|
+
/** The trollbox: a chat per live room, kept at nixamp.com. */
|
|
660
|
+
trollbox?: Trollbox;
|
|
658
661
|
/** Tickets: a paid pass to one event's room. Absent means every show is free. */
|
|
659
662
|
tickets?: Tickets;
|
|
660
663
|
/**
|
package/dist/server.js
CHANGED
|
@@ -62,6 +62,7 @@ import { LiveEvents, eventStructuredData, isTicketed } from "./live-events.js";
|
|
|
62
62
|
import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.js";
|
|
63
63
|
import { Layouts } from "./layouts.js";
|
|
64
64
|
import { Rooms } from "./rooms.js";
|
|
65
|
+
import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.js";
|
|
65
66
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
66
67
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
67
68
|
import { isRemote, isTransportStream, playsInBrowser, sourceLabel } from "./sources.js";
|
|
@@ -2040,6 +2041,92 @@ export function createHandler(engine, options) {
|
|
|
2040
2041
|
// Separate from the address on purpose. The address is a credential and
|
|
2041
2042
|
// a way to reach somebody; publishing it in a directory listing or an
|
|
2042
2043
|
// invite would be publishing what they log in with.
|
|
2044
|
+
/*
|
|
2045
|
+
* The trollbox: the chat for one live room, keyed by the server and
|
|
2046
|
+
* the channel so every viewer of a stream is in the same box whichever
|
|
2047
|
+
* page they came from. Reading is open; a line needs a nixamp.com
|
|
2048
|
+
* account and is signed with its public handle; taking one down is the
|
|
2049
|
+
* author's, or the listing owner's.
|
|
2050
|
+
*/
|
|
2051
|
+
if ((path === "/api/v1/trollbox" || path.startsWith("/api/v1/trollbox/")) && options.trollbox && options.accounts) {
|
|
2052
|
+
const trollbox = options.trollbox;
|
|
2053
|
+
const child = path.startsWith("/api/v1/trollbox/") ? path.slice("/api/v1/trollbox/".length) : "";
|
|
2054
|
+
try {
|
|
2055
|
+
if (request.method === "GET" && child === "") {
|
|
2056
|
+
const where = roomFor(url.searchParams.get("server"), url.searchParams.get("channel"));
|
|
2057
|
+
if (!where) {
|
|
2058
|
+
json(response, 400, { error: "a room is a server address and a channel" });
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
2062
|
+
const lines = await trollbox.messages(where.room, url.searchParams.get("after") ?? undefined);
|
|
2063
|
+
json(response, 200, {
|
|
2064
|
+
room: where.room,
|
|
2065
|
+
you: who ? ((await options.handles?.of(who.id)) || fallbackHandle(who.id)) : "",
|
|
2066
|
+
messages: lines.map((one) => ({
|
|
2067
|
+
id: one.id, handle: one.handle, body: one.body, createdAt: one.createdAt, mine: who !== null && one.authorId === who.id,
|
|
2068
|
+
})),
|
|
2069
|
+
});
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
if (request.method === "POST" && child === "") {
|
|
2073
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
2074
|
+
if (who === null) {
|
|
2075
|
+
json(response, 401, { error: "sign in to nixamp.com to chat" });
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
let body = {};
|
|
2079
|
+
try {
|
|
2080
|
+
body = JSON.parse(await readBody(request));
|
|
2081
|
+
}
|
|
2082
|
+
catch {
|
|
2083
|
+
json(response, 400, { error: "bad JSON" });
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
const where = roomFor(body.server, body.channel);
|
|
2087
|
+
if (!where) {
|
|
2088
|
+
json(response, 400, { error: "a room is a server address and a channel" });
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
|
|
2092
|
+
const line = await trollbox.post(where, who.id, handle, body.body);
|
|
2093
|
+
json(response, 201, { message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true } });
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
if (request.method === "DELETE" && child !== "") {
|
|
2097
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
2098
|
+
if (who === null) {
|
|
2099
|
+
json(response, 401, { error: "sign in to nixamp.com first" });
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
const where = roomFor(url.searchParams.get("server"), url.searchParams.get("channel"));
|
|
2103
|
+
if (!where) {
|
|
2104
|
+
json(response, 400, { error: "a room is a server address and a channel" });
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
// The listing owner moderates their own server's rooms.
|
|
2108
|
+
const moderator = (options.directory?.list() ?? []).some((listing) => {
|
|
2109
|
+
try {
|
|
2110
|
+
return listing.ownerId === who.id && new URL(listing.url).origin === where.server;
|
|
2111
|
+
}
|
|
2112
|
+
catch {
|
|
2113
|
+
return false;
|
|
2114
|
+
}
|
|
2115
|
+
});
|
|
2116
|
+
const removed = await trollbox.remove(where.room, child, who.id, moderator);
|
|
2117
|
+
json(response, removed ? 200 : 404, removed ? { ok: true } : { error: "not your line, or already gone" });
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
2121
|
+
}
|
|
2122
|
+
catch (error) {
|
|
2123
|
+
if (error instanceof TrollboxError)
|
|
2124
|
+
json(response, error.status, { error: error.message });
|
|
2125
|
+
else
|
|
2126
|
+
throw error;
|
|
2127
|
+
}
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2043
2130
|
if (path === "/api/v1/me/handle" && options.handles && options.accounts) {
|
|
2044
2131
|
const handles = options.handles;
|
|
2045
2132
|
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
@@ -4345,6 +4432,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4345
4432
|
const events = pool ? new LiveEvents(pool) : undefined;
|
|
4346
4433
|
const layouts = pool ? new Layouts(pool) : undefined;
|
|
4347
4434
|
const rooms = pool ? new Rooms(pool) : undefined;
|
|
4435
|
+
const trollbox = pool ? new Trollbox(pool) : undefined;
|
|
4348
4436
|
// Tickets need somewhere for the money to go and a key to settle it with.
|
|
4349
4437
|
// Without a CoinPay key every event is simply a free one.
|
|
4350
4438
|
const ticketConfig = events
|
|
@@ -4735,6 +4823,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4735
4823
|
...(events ? { events } : {}),
|
|
4736
4824
|
...(layouts ? { layouts } : {}),
|
|
4737
4825
|
...(rooms ? { rooms } : {}),
|
|
4826
|
+
...(trollbox ? { trollbox } : {}),
|
|
4738
4827
|
...(tickets ? { tickets } : {}),
|
|
4739
4828
|
...(authServer ? { authServer } : {}),
|
|
4740
4829
|
...(parties ? { parties } : {}),
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Queryable } from "./follows.ts";
|
|
2
|
+
export interface TrollboxMessage {
|
|
3
|
+
id: string;
|
|
4
|
+
room: string;
|
|
5
|
+
authorId: string;
|
|
6
|
+
handle: string;
|
|
7
|
+
body: string;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
}
|
|
10
|
+
export interface TrollboxRoom {
|
|
11
|
+
/** The key: short, opaque, the same for every viewer of one stream. */
|
|
12
|
+
room: string;
|
|
13
|
+
/** The server, as an origin: scheme, host and port, no path and no key. */
|
|
14
|
+
server: string;
|
|
15
|
+
/** The channel's id on that server, or "live" for the server's own stream. */
|
|
16
|
+
channel: string;
|
|
17
|
+
}
|
|
18
|
+
export declare class TrollboxError extends Error {
|
|
19
|
+
readonly status: number;
|
|
20
|
+
constructor(message: string, status: number);
|
|
21
|
+
}
|
|
22
|
+
export declare const BODY_LIMIT = 500;
|
|
23
|
+
/** How often one person may speak: one line a second, and twenty a minute. */
|
|
24
|
+
export declare const LINES_PER_MINUTE = 20;
|
|
25
|
+
export declare const LINE_GAP_MS = 1000;
|
|
26
|
+
/**
|
|
27
|
+
* Which room a server and a channel name: the server reduced to its origin,
|
|
28
|
+
* so a view link, an admin link and a bare address all land in one room and
|
|
29
|
+
* no key is ever kept; the channel as the page names it. Null when it is
|
|
30
|
+
* not a room this can keep.
|
|
31
|
+
*/
|
|
32
|
+
export declare function roomFor(server: unknown, channel: unknown): TrollboxRoom | null;
|
|
33
|
+
/** A handle for somebody who never picked one: stable for them, and not their address. */
|
|
34
|
+
export declare function fallbackHandle(accountId: string): string;
|
|
35
|
+
export declare class Trollbox {
|
|
36
|
+
private readonly db;
|
|
37
|
+
private readonly now;
|
|
38
|
+
private ready;
|
|
39
|
+
/** When each account last spoke, and how often this minute: the throttle. */
|
|
40
|
+
private readonly spoke;
|
|
41
|
+
constructor(db: Queryable, now?: () => number);
|
|
42
|
+
private ensure;
|
|
43
|
+
/** The room's lines, oldest first, after a moment when given. */
|
|
44
|
+
messages(room: string, after?: string, limit?: number): Promise<TrollboxMessage[]>;
|
|
45
|
+
/** Whether this account may speak now, and the bookkeeping if so. */
|
|
46
|
+
private allow;
|
|
47
|
+
post(where: TrollboxRoom, authorId: string, handle: string, body: unknown): Promise<TrollboxMessage>;
|
|
48
|
+
/**
|
|
49
|
+
* A line taken down: by its author, or by a moderator (whoever owns the
|
|
50
|
+
* server's listing). Anybody else's ask removes nothing and says so.
|
|
51
|
+
*/
|
|
52
|
+
remove(room: string, id: string, byAccount: string, moderator: boolean): Promise<boolean>;
|
|
53
|
+
}
|
package/dist/trollbox.js
ADDED
|
Binary file
|
package/package.json
CHANGED
package/src/server.ts
CHANGED
|
@@ -83,6 +83,7 @@ import { LiveEvents, eventStructuredData, isTicketed, type LiveEvent } from "./l
|
|
|
83
83
|
import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.ts";
|
|
84
84
|
import { Layouts } from "./layouts.ts";
|
|
85
85
|
import { Rooms } from "./rooms.ts";
|
|
86
|
+
import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.ts";
|
|
86
87
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
87
88
|
import {
|
|
88
89
|
applyRemoteConfig,
|
|
@@ -1581,6 +1582,8 @@ export interface HandlerOptions {
|
|
|
1581
1582
|
layouts?: Layouts;
|
|
1582
1583
|
/** Persistent participation state that must not be coupled to live audio. */
|
|
1583
1584
|
rooms?: Rooms;
|
|
1585
|
+
/** The trollbox: a chat per live room, kept at nixamp.com. */
|
|
1586
|
+
trollbox?: Trollbox;
|
|
1584
1587
|
/** Tickets: a paid pass to one event's room. Absent means every show is free. */
|
|
1585
1588
|
tickets?: Tickets;
|
|
1586
1589
|
/**
|
|
@@ -2526,6 +2529,88 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2526
2529
|
// Separate from the address on purpose. The address is a credential and
|
|
2527
2530
|
// a way to reach somebody; publishing it in a directory listing or an
|
|
2528
2531
|
// invite would be publishing what they log in with.
|
|
2532
|
+
/*
|
|
2533
|
+
* The trollbox: the chat for one live room, keyed by the server and
|
|
2534
|
+
* the channel so every viewer of a stream is in the same box whichever
|
|
2535
|
+
* page they came from. Reading is open; a line needs a nixamp.com
|
|
2536
|
+
* account and is signed with its public handle; taking one down is the
|
|
2537
|
+
* author's, or the listing owner's.
|
|
2538
|
+
*/
|
|
2539
|
+
if ((path === "/api/v1/trollbox" || path.startsWith("/api/v1/trollbox/")) && options.trollbox && options.accounts) {
|
|
2540
|
+
const trollbox = options.trollbox;
|
|
2541
|
+
const child = path.startsWith("/api/v1/trollbox/") ? path.slice("/api/v1/trollbox/".length) : "";
|
|
2542
|
+
try {
|
|
2543
|
+
if (request.method === "GET" && child === "") {
|
|
2544
|
+
const where = roomFor(url.searchParams.get("server"), url.searchParams.get("channel"));
|
|
2545
|
+
if (!where) {
|
|
2546
|
+
json(response, 400, { error: "a room is a server address and a channel" });
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
2550
|
+
const lines = await trollbox.messages(where.room, url.searchParams.get("after") ?? undefined);
|
|
2551
|
+
json(response, 200, {
|
|
2552
|
+
room: where.room,
|
|
2553
|
+
you: who ? ((await options.handles?.of(who.id)) || fallbackHandle(who.id)) : "",
|
|
2554
|
+
messages: lines.map((one) => ({
|
|
2555
|
+
id: one.id, handle: one.handle, body: one.body, createdAt: one.createdAt, mine: who !== null && one.authorId === who.id,
|
|
2556
|
+
})),
|
|
2557
|
+
});
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
if (request.method === "POST" && child === "") {
|
|
2561
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
2562
|
+
if (who === null) {
|
|
2563
|
+
json(response, 401, { error: "sign in to nixamp.com to chat" });
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
let body: { server?: unknown; channel?: unknown; body?: unknown } = {};
|
|
2567
|
+
try {
|
|
2568
|
+
body = JSON.parse(await readBody(request)) as typeof body;
|
|
2569
|
+
} catch {
|
|
2570
|
+
json(response, 400, { error: "bad JSON" });
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
const where = roomFor(body.server, body.channel);
|
|
2574
|
+
if (!where) {
|
|
2575
|
+
json(response, 400, { error: "a room is a server address and a channel" });
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
|
|
2579
|
+
const line = await trollbox.post(where, who.id, handle, body.body);
|
|
2580
|
+
json(response, 201, { message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true } });
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
if (request.method === "DELETE" && child !== "") {
|
|
2584
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
2585
|
+
if (who === null) {
|
|
2586
|
+
json(response, 401, { error: "sign in to nixamp.com first" });
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
const where = roomFor(url.searchParams.get("server"), url.searchParams.get("channel"));
|
|
2590
|
+
if (!where) {
|
|
2591
|
+
json(response, 400, { error: "a room is a server address and a channel" });
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
// The listing owner moderates their own server's rooms.
|
|
2595
|
+
const moderator = (options.directory?.list() ?? []).some((listing) => {
|
|
2596
|
+
try {
|
|
2597
|
+
return listing.ownerId === who.id && new URL(listing.url).origin === where.server;
|
|
2598
|
+
} catch {
|
|
2599
|
+
return false;
|
|
2600
|
+
}
|
|
2601
|
+
});
|
|
2602
|
+
const removed = await trollbox.remove(where.room, child, who.id, moderator);
|
|
2603
|
+
json(response, removed ? 200 : 404, removed ? { ok: true } : { error: "not your line, or already gone" });
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
2607
|
+
} catch (error) {
|
|
2608
|
+
if (error instanceof TrollboxError) json(response, error.status, { error: error.message });
|
|
2609
|
+
else throw error;
|
|
2610
|
+
}
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2529
2614
|
if (path === "/api/v1/me/handle" && options.handles && options.accounts) {
|
|
2530
2615
|
const handles = options.handles;
|
|
2531
2616
|
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
@@ -4946,6 +5031,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4946
5031
|
const events = pool ? new LiveEvents(pool) : undefined;
|
|
4947
5032
|
const layouts = pool ? new Layouts(pool) : undefined;
|
|
4948
5033
|
const rooms = pool ? new Rooms(pool) : undefined;
|
|
5034
|
+
const trollbox = pool ? new Trollbox(pool) : undefined;
|
|
4949
5035
|
// Tickets need somewhere for the money to go and a key to settle it with.
|
|
4950
5036
|
// Without a CoinPay key every event is simply a free one.
|
|
4951
5037
|
const ticketConfig = events
|
|
@@ -5353,6 +5439,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5353
5439
|
...(events ? { events } : {}),
|
|
5354
5440
|
...(layouts ? { layouts } : {}),
|
|
5355
5441
|
...(rooms ? { rooms } : {}),
|
|
5442
|
+
...(trollbox ? { trollbox } : {}),
|
|
5356
5443
|
...(tickets ? { tickets } : {}),
|
|
5357
5444
|
...(authServer ? { authServer } : {}),
|
|
5358
5445
|
...(parties ? { parties } : {}),
|
package/src/trollbox.ts
ADDED
|
Binary file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-bWLeXdTo.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;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.status[data-playing=loading]{color:var(--accent)}.status[data-playing=loading]:before,.row.loading>.name:after{content:"";vertical-align:-.1em;border:2px solid;border-right-color:#0000;border-radius:50%;width:.8em;height:.8em;animation:.8s linear infinite turn;display:inline-block}.status[data-playing=loading]:before{margin-right:6px}.row.loading>.name:after{color:var(--accent);margin-left:8px}@keyframes turn{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.status[data-playing=loading]:before,.row.loading>.name:after{animation-duration:2.4s}}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{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)}.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}.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}.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}
|