nixamp 0.15.1 → 0.17.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/README.md +175 -0
- package/backtoschool/dist/assets/index-6vtCMzi1.css +1 -0
- package/backtoschool/dist/assets/index-D7Oas1m5.js +88 -0
- package/backtoschool/dist/icon.svg +7 -0
- package/backtoschool/dist/index.html +97 -0
- package/backtoschool/dist/manifest.webmanifest +17 -0
- package/dist/audio.d.ts +70 -1
- package/dist/audio.js +84 -5
- package/dist/channels.d.ts +117 -3
- package/dist/channels.js +159 -3
- package/dist/compression/analyze.d.ts +105 -0
- package/dist/compression/analyze.js +213 -0
- package/dist/compression/blocks.d.ts +35 -0
- package/dist/compression/blocks.js +73 -0
- package/dist/compression/cli.d.ts +1 -0
- package/dist/compression/cli.js +347 -0
- package/dist/compression/codec.d.ts +66 -0
- package/dist/compression/codec.js +178 -0
- package/dist/compression/envelope.d.ts +77 -0
- package/dist/compression/envelope.js +191 -0
- package/dist/compression/jobs.d.ts +59 -0
- package/dist/compression/jobs.js +120 -0
- package/dist/compression/metrics.d.ts +62 -0
- package/dist/compression/metrics.js +64 -0
- package/dist/compression/policy.d.ts +76 -0
- package/dist/compression/policy.js +148 -0
- package/dist/compression/receiver.d.ts +51 -0
- package/dist/compression/receiver.js +92 -0
- package/dist/compression/relay.d.ts +134 -0
- package/dist/compression/relay.js +430 -0
- package/dist/compression/routes.d.ts +25 -0
- package/dist/compression/routes.js +266 -0
- package/dist/compression/service.d.ts +162 -0
- package/dist/compression/service.js +488 -0
- package/dist/compression/static.d.ts +65 -0
- package/dist/compression/static.js +248 -0
- package/dist/compression/store.d.ts +36 -0
- package/dist/compression/store.js +119 -0
- package/dist/compression/ts-transform.d.ts +44 -0
- package/dist/compression/ts-transform.js +148 -0
- package/dist/hls.d.ts +36 -3
- package/dist/hls.js +85 -11
- package/dist/layouts.d.ts +78 -0
- package/dist/layouts.js +423 -0
- package/dist/live-api.d.ts +34 -0
- package/dist/live-api.js +580 -0
- package/dist/live-events.d.ts +162 -0
- package/dist/live-events.js +752 -0
- package/dist/main.js +47 -0
- package/dist/mcp.d.ts +40 -0
- package/dist/mcp.js +255 -0
- package/dist/oauth-api.d.ts +50 -0
- package/dist/oauth-api.js +381 -0
- package/dist/oauth-server.d.ts +174 -0
- package/dist/oauth-server.js +559 -0
- package/dist/owner.d.ts +12 -0
- package/dist/owner.js +27 -0
- package/dist/party.d.ts +32 -0
- package/dist/party.js +196 -0
- package/dist/playlist.d.ts +11 -0
- package/dist/playlist.js +27 -6
- package/dist/rooms.d.ts +38 -0
- package/dist/rooms.js +150 -0
- package/dist/server.d.ts +57 -0
- package/dist/server.js +549 -31
- package/dist/sources.d.ts +16 -8
- package/dist/sources.js +102 -0
- package/dist/tickets.d.ts +67 -0
- package/dist/tickets.js +156 -0
- package/dist/tokens.d.ts +27 -2
- package/dist/tokens.js +42 -1
- package/dist/watch-party.d.ts +127 -0
- package/dist/watch-party.js +301 -0
- package/package.json +7 -2
- package/src/audio.ts +119 -6
- package/src/channels.ts +192 -7
- package/src/compression/analyze.ts +270 -0
- package/src/compression/blocks.ts +93 -0
- package/src/compression/cli.ts +340 -0
- package/src/compression/codec.ts +202 -0
- package/src/compression/envelope.ts +237 -0
- package/src/compression/jobs.ts +157 -0
- package/src/compression/metrics.ts +109 -0
- package/src/compression/policy.ts +171 -0
- package/src/compression/receiver.ts +107 -0
- package/src/compression/relay.ts +462 -0
- package/src/compression/routes.ts +294 -0
- package/src/compression/service.ts +525 -0
- package/src/compression/static.ts +264 -0
- package/src/compression/store.ts +126 -0
- package/src/compression/ts-transform.ts +148 -0
- package/src/hls.ts +100 -10
- package/src/layouts.ts +491 -0
- package/src/live-api.ts +647 -0
- package/src/live-events.ts +895 -0
- package/src/main.ts +47 -0
- package/src/mcp.ts +282 -0
- package/src/oauth-api.ts +481 -0
- package/src/oauth-server.ts +653 -0
- package/src/owner.ts +23 -0
- package/src/party.ts +216 -0
- package/src/playlist.ts +28 -5
- package/src/rooms.ts +192 -0
- package/src/server.ts +577 -30
- package/src/sources.ts +100 -0
- package/src/tickets.ts +195 -0
- package/src/tokens.ts +50 -3
- package/src/watch-party.ts +390 -0
- package/web/dist/assets/{hls-3VKVEQE3-_qQT5aJ-.js → hls-3VKVEQE3-eV54kXE3.js} +1 -1
- package/web/dist/assets/index-CurZFzlH.css +1 -0
- package/web/dist/assets/index-DDzutJ75.js +1 -0
- package/web/dist/assets/{mpegts-DOPWPErb.js → mpegts-Buc3Odv6.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-yFFz0FUw.js → mpegts-LO6RVLD6-DcDKPB4P.js} +1 -1
- package/web/dist/index.html +59 -6
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-CQ_m5HqS.css +0 -1
- package/web/dist/assets/index-mrvRL9c4.js +0 -1
package/README.md
CHANGED
|
@@ -136,6 +136,135 @@ DATABASE_URL=postgres://user:pass@host/nixamp NIXAMP_JWT_SECRET=… nixamp serve
|
|
|
136
136
|
Accounts live where the directory lives and nowhere else: a nixamp on a laptop
|
|
137
137
|
has nobody to be an account of.
|
|
138
138
|
|
|
139
|
+
## Watch parties, and signing in with nixamp
|
|
140
|
+
|
|
141
|
+
A watch party lives on the site that has the film. bittorrented.com has them:
|
|
142
|
+
a six-character code, a host, and everybody at the same second. nixamp has
|
|
143
|
+
rooms, chat, invitations, a directory, and five clients that can already open
|
|
144
|
+
one. A bridged party is both.
|
|
145
|
+
|
|
146
|
+
The identity link is **OAuth 2.1**, with nixamp.com as the authorization
|
|
147
|
+
server. The site sends somebody here, they approve it once, and the site holds
|
|
148
|
+
a token that acts on their nixamp account. It is 2.1 and not 2.0, so:
|
|
149
|
+
|
|
150
|
+
- authorization code only, with PKCE (S256) required of every client, public
|
|
151
|
+
or confidential. No implicit grant, no password grant.
|
|
152
|
+
- redirect URIs match the registered string exactly; only a loopback port may
|
|
153
|
+
vary, because a CLI cannot know its port before it listens.
|
|
154
|
+
- a code is spent once; presenting it twice withdraws everything it produced.
|
|
155
|
+
- refresh tokens rotate, and a retired one presented again withdraws the whole
|
|
156
|
+
family.
|
|
157
|
+
|
|
158
|
+
The endpoints are where RFC 8414 says to look for them:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
GET /.well-known/oauth-authorization-server
|
|
162
|
+
GET /api/v1/oauth/authorize the consent page
|
|
163
|
+
POST /api/v1/oauth/token authorization_code, refresh_token
|
|
164
|
+
POST /api/v1/oauth/revoke
|
|
165
|
+
GET /api/v1/oauth/userinfo
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Scopes are `profile`, `email`, `parties` and `offline_access`. The Account
|
|
169
|
+
panel on nixamp.com lists what is connected and takes it away again.
|
|
170
|
+
|
|
171
|
+
bittorrented.com is registered out of the box. Another client is added with
|
|
172
|
+
`NIXAMP_OAUTH_CLIENTS`, a JSON list:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
NIXAMP_OAUTH_CLIENTS='[{"id":"example","name":"Example","redirectUris":["https://example.com/cb"]}]'
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Once a party is bridged it is an ordinary live event with a room, so every
|
|
179
|
+
surface already knows what to do with it:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
nixamp party list the ones you could join right now
|
|
183
|
+
nixamp party join ABC123 --open the room here, the film where it lives
|
|
184
|
+
nixamp party host ABC123 --url URL put one on the air as a nixamp room
|
|
185
|
+
nixamp party sync ABC123 --at 930 where playback is (hosts only)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
and an agent reaches the same five actions over the Model Context Protocol:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
nixamp mcp a stdio MCP server: list, get, host, sync, end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
It acts as whoever the machine is signed in as, so `nixamp login` comes first.
|
|
195
|
+
The film never crosses over: what nixamp carries is the room.
|
|
196
|
+
|
|
197
|
+
## BackToSchool.help
|
|
198
|
+
|
|
199
|
+
BackToSchool.help is a branded, mobile-first client for NixAmp live events. It
|
|
200
|
+
uses the same NixAmp accounts, PostgreSQL data, rooms, invitations, layouts, and
|
|
201
|
+
channel transport as the main app; it has no separate backend or user store.
|
|
202
|
+
|
|
203
|
+
Build the server and both web clients from the repository root:
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
bun install --frozen-lockfile
|
|
207
|
+
bun run build
|
|
208
|
+
bun run web:build
|
|
209
|
+
bun run backtoschool:build
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
A directory deployment can serve the BackToSchool client instead of the default
|
|
213
|
+
NixAmp PWA by pointing `--web` at its build output:
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
DATABASE_URL=postgres://user:pass@host/nixamp \
|
|
217
|
+
NIXAMP_JWT_SECRET=replace-with-a-long-random-secret \
|
|
218
|
+
NIXAMP_SITE=https://backtoschool.help \
|
|
219
|
+
bun src/main.ts serve /srv/nixamp/media \
|
|
220
|
+
--directory --web "$PWD/backtoschool/dist" --host 127.0.0.1 --port 4321 --no-publish
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Put an HTTPS reverse proxy for `backtoschool.help` in front of that port and
|
|
224
|
+
forward the whole origin, including `/api` and `/live`. Do not buffer responses
|
|
225
|
+
under `/api/channels/`; those responses carry live audio. Keeping the client and
|
|
226
|
+
API on one origin lets the HttpOnly NixAmp session cookie authenticate hosting,
|
|
227
|
+
chat, invitations, and moderation. HTTPS is also required for browser microphone
|
|
228
|
+
access outside localhost. Event, room, layout, and invitation tables are created
|
|
229
|
+
on first use in the configured PostgreSQL database. `RESEND_API_KEY` and
|
|
230
|
+
`NIXAMP_MAIL_FROM` are optional if invitation email should be sent rather than
|
|
231
|
+
only returning a shareable link.
|
|
232
|
+
|
|
233
|
+
## Live shows, and tickets
|
|
234
|
+
|
|
235
|
+
A live event carries a **kind**: `talk`, `class`, or `concert`. The kind is
|
|
236
|
+
what a branded client reads to pick a layout, and what `/api/v1/events?kind=`
|
|
237
|
+
filters the directory by, so one NixAmp serves a school and a venue without
|
|
238
|
+
either knowing about the other. `concert` brings its own presets
|
|
239
|
+
(`concert-viewer`, `concert-ticketholder`, `concert-artist`) with a stage,
|
|
240
|
+
a setlist, a tip jar, a merch shelf and a till.
|
|
241
|
+
|
|
242
|
+
A concert also has doors and an encore. `POST /api/v1/events/:id/doors` opens
|
|
243
|
+
the room before the music, `.../start` begins it, `.../encore` says the band
|
|
244
|
+
came back, and `.../end` closes it. Opening, playing and coming back on are
|
|
245
|
+
allowed to everyone on the stage; cancelling and archiving stay with the host.
|
|
246
|
+
An **artist** is an invitation role beside moderator: they perform without
|
|
247
|
+
being handed the guest list.
|
|
248
|
+
|
|
249
|
+
**A ticket is a paid pass to one room**, over x402 and settled by CoinPay,
|
|
250
|
+
exactly like the crawler paywall but scoped to a single event:
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
POST /api/v1/events/:id/tickets # X-PAYMENT proof in, ticket out
|
|
254
|
+
GET /api/v1/events/:id/tickets # what it costs and whether you hold one
|
|
255
|
+
POST /api/v1/events/:id/tickets/comp # the guest list, hosts only
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Set `ticketPriceCents` and a `payTo` address on the event and the room answers
|
|
259
|
+
402 to anyone without a ticket, quoting the price; the money goes to the
|
|
260
|
+
event's own address, never to the platform. The ticket rides in
|
|
261
|
+
`x-nixamp-ticket`, or in `?ticket=` for an `<audio>` or `<video>` element that
|
|
262
|
+
cannot set a header. `COINPAY_X402_KEY` switches sales on; without it every
|
|
263
|
+
event is simply a free one. `NIXAMP_TICKET_SECRET` signs the passes (it
|
|
264
|
+
defaults to the CoinPay key), and each event's tickets are signed with a
|
|
265
|
+
secret derived from it and the event id, so a ticket to Friday is not a ticket
|
|
266
|
+
to Saturday.
|
|
267
|
+
|
|
139
268
|
## The directory
|
|
140
269
|
|
|
141
270
|
[nixamp.com/directory](https://nixamp.com/directory) lists nixamps that agreed
|
|
@@ -187,6 +316,39 @@ is the whole point.
|
|
|
187
316
|
Publishing is administering the server, so it needs the control link or the
|
|
188
317
|
owner's account. Listening only needs the share link, like any other audio.
|
|
189
318
|
|
|
319
|
+
### Relaying a channel to another nixamp, compressed
|
|
320
|
+
|
|
321
|
+
A channel can be carried from one nixamp to another with fewer bytes on the
|
|
322
|
+
wire and every byte restored at the far end. It is off until you turn it on,
|
|
323
|
+
per channel, and nothing about ordinary playback changes when you do.
|
|
324
|
+
|
|
325
|
+
```
|
|
326
|
+
nixamp compression analyze --channel cnn what a codec would make of it
|
|
327
|
+
nixamp compression set --channel cnn --mode auto compress when it pays, store when it does not
|
|
328
|
+
nixamp compression status --channel cnn what it is doing, in bytes
|
|
329
|
+
nixamp compression off the whole server, at once
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
On the receiving nixamp:
|
|
333
|
+
|
|
334
|
+
```
|
|
335
|
+
nixamp compression pull --channel cnn --from https://host:4321/api/channels/cnn/relay --from-key KEY
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
and `cnn` is a channel there, heard at `/api/channels/cnn` like any other.
|
|
339
|
+
The relay is `GET /api/channels/<id>/relay` as `application/vnd.nixamp.stream`,
|
|
340
|
+
a framed stream of Zstandard blocks each carrying the length and SHA-256 of
|
|
341
|
+
what it stands for, ending in a marker; a block that would not shrink is
|
|
342
|
+
sent as it is, and the metrics say so rather than claiming a saving. A
|
|
343
|
+
library file gets the same treatment at `/api/media/<n>/relay`, built once
|
|
344
|
+
and kept. `nixamp compression analyze FILE` measures a file here with no
|
|
345
|
+
server at all. The wire format, the policy, the limits and the switch are
|
|
346
|
+
in [docs/stream-compression.md](docs/stream-compression.md).
|
|
347
|
+
|
|
348
|
+
HLS can be packaged as fragmented MP4 instead of MPEG-TS
|
|
349
|
+
(`--hls fmp4` on `compression set`, or server-wide): the same boxes the
|
|
350
|
+
channel already carries, copied into files, never re-encoded.
|
|
351
|
+
|
|
190
352
|
## Streaming into it
|
|
191
353
|
|
|
192
354
|
A nixamp can be the thing you broadcast *to*, not just from.
|
|
@@ -476,6 +638,19 @@ A bare `ffmpeg` on `PATH` is used when there is one; `mise` shims are detected a
|
|
|
476
638
|
|
|
477
639
|
Whatever your ffmpeg was built with: mp3, flac, ogg, opus, m4a, aac, wav, wma, aiff, alac, and the audio track of mp4 and webm.
|
|
478
640
|
|
|
641
|
+
Video too, including raw transport streams — a `.ts`, `.m2ts` or `.mts` off a
|
|
642
|
+
capture card, a receiver or an IPTV recorder, at 1080p or 4K. H.264 is copied
|
|
643
|
+
into the fragmented MP4 a browser is sent, at whatever size it already is, so
|
|
644
|
+
a 4K recording costs no encoding to watch or to put on the air. H.265 is
|
|
645
|
+
copied too when the browser asking for it says it can decode one, and
|
|
646
|
+
otherwise re-encoded down to 1080p, because a 4K encode does not keep up with
|
|
647
|
+
playing it. A channel, which has one encode and a whole audience, re-encodes
|
|
648
|
+
H.265 by default; `NIXAMP_HEVC_CHANNELS=1` copies it through instead, for an
|
|
649
|
+
audience of phones and televisions.
|
|
650
|
+
|
|
651
|
+
A `.ts` is opened rather than taken on its name: it is as often a TypeScript
|
|
652
|
+
file as a transport stream, and a checkout is not a playlist.
|
|
653
|
+
|
|
479
654
|
## Status
|
|
480
655
|
|
|
481
656
|
Early. It plays a directory, shows tags and timings, and draws what it hears —
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--ink:#201a32;--purple:#34246d;--purple-2:#5b43a4;--coral:#f56f5d;--yellow:#ffd465;--paper:#f7f2e8;--paper-deep:#eee5d6;--white:#fffdf8;--green:#217a53;--muted:#6f6879;--line:#201a3224;--shadow:0 18px 50px #34246d1f;color:var(--ink);background:var(--paper);font-synthesis:none;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{scroll-behavior:smooth}body{background:var(--paper);min-width:320px;min-height:100vh;margin:0}button,input,select,textarea{font:inherit}button,a{-webkit-tap-highlight-color:transparent}a{color:inherit}[hidden]{display:none!important}body:before{content:"";pointer-events:none;opacity:.34;background-image:radial-gradient(#34246d1f .65px,#0000 .65px);background-size:7px 7px;position:fixed;inset:0;-webkit-mask-image:linear-gradient(#000,#0000 55%);mask-image:linear-gradient(#000,#0000 55%)}#app{flex-direction:column;min-height:100vh;display:flex;position:relative}.site-header,main,.site-footer{width:min(1180px,100% - 40px);margin-inline:auto}.site-header{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:24px;min-height:82px;display:flex}.wordmark{color:var(--purple);letter-spacing:-.035em;align-items:center;gap:10px;font-size:1.12rem;font-weight:850;text-decoration:none;display:inline-flex}.wordmark>span:last-child span{color:var(--coral)}.wordmark-mark{width:34px;height:34px;color:var(--white);background:var(--purple);box-shadow:3px 3px 0 var(--yellow);border-radius:10px 10px 10px 3px;place-items:center;font-family:Georgia,serif;font-size:1.2rem;display:grid;transform:rotate(-3deg)}.site-nav{align-items:center;gap:28px;font-size:.91rem;display:flex}.site-nav a{color:var(--muted);text-decoration:none}.site-nav a:hover{color:var(--purple)}main{outline:none;flex:1}.loading-page,.error-page{text-align:center;place-content:center;min-height:68vh;display:grid}.loading-page h1,.error-page h1{color:var(--purple);margin:8px 0;font:700 clamp(2.4rem,8vw,5rem)/1 Georgia,serif}.loading-page p,.error-page p{color:var(--muted)}.hero{grid-template-columns:minmax(0,1.05fr) minmax(360px,.8fr);align-items:center;gap:clamp(50px,8vw,110px);min-height:610px;padding:92px 0 78px;display:grid}.eyebrow{color:var(--coral);letter-spacing:.13em;text-transform:uppercase;font-size:.75rem;font-weight:850;display:block}.hero h1{max-width:690px;color:var(--purple);letter-spacing:-.065em;margin:14px 0 24px;font:750 clamp(3.6rem,8vw,7.3rem)/.86 Georgia,Times New Roman,serif}.hero h1 em{color:var(--coral);font-weight:inherit}.hero-copy>p{max-width:590px;color:var(--muted);margin:0;font-size:clamp(1.02rem,1.5vw,1.22rem);line-height:1.7}.hero-actions{flex-wrap:wrap;gap:12px;margin-top:34px;display:flex}.hero-copy .hero-note{margin-top:15px;font-size:.82rem}.button{cursor:pointer;border:0;border-radius:999px;justify-content:center;align-items:center;gap:9px;min-height:44px;padding:0 22px;font-weight:780;text-decoration:none;transition:transform .16s,box-shadow .16s,background .16s;display:inline-flex}.button:hover:not(:disabled){transform:translateY(-2px)}.button:focus-visible,.text-button:focus-visible,a:focus-visible{outline-offset:3px;outline:3px solid #f56f5d66}.button:disabled{cursor:wait;opacity:.65}.button-primary{color:#fff;background:var(--purple);box-shadow:0 8px 22px #34246d3d}.button-primary:hover:not(:disabled){background:var(--purple-2);box-shadow:0 12px 28px #34246d47}.button-secondary{color:var(--purple);background:0 0;border:1px solid #34246d47}.button-light{color:var(--purple);background:var(--white)}.button-danger{color:#902d27;background:#ffe5df}.button-quiet{min-height:36px;color:var(--purple);background:#34246d12;padding-inline:17px}.button-quiet.signed-in{color:var(--green);background:#217a531a}.button-small{color:#fff;background:var(--purple);min-height:38px;padding-inline:16px;font-size:.84rem}.live-dot{background:var(--coral);border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #f56f5d2e}.hero-board{color:#fffbe9;background:#285e4d;border:10px solid #bd8654;border-radius:6px;flex-direction:column;justify-content:center;min-height:390px;padding:58px 50px 44px;display:flex;position:relative;transform:rotate(1.2deg);box-shadow:0 26px 60px #201a3233,inset 0 0 50px #0000001f}.hero-board:before{content:"";opacity:.2;background-image:linear-gradient(95deg,#0000 49%,#fff3 50%,#0000 52%),radial-gradient(#fff 0,#0000 55%);background-size:220px 100%,210px 35px;position:absolute;inset:0}.tape{z-index:2;background:#ffdd8dc2;width:86px;height:28px;position:absolute}.tape-one{top:-23px;left:40px;transform:rotate(-7deg)}.tape-two{bottom:-20px;right:28px;transform:rotate(-5deg)}.board-label{opacity:.75;font:italic 1rem Georgia,serif;position:relative}.hero-board strong{letter-spacing:-.035em;max-width:390px;margin:18px 0 15px;font:600 clamp(2rem,4vw,3.2rem)/1.05 Georgia,serif;position:relative}.hero-board p{opacity:.72;margin:0;position:relative}.board-action{color:#fff;cursor:pointer;background:0 0;border:0;border-bottom:1px solid #ffffff80;align-self:flex-start;margin-top:34px;padding-bottom:4px;font-weight:750;text-decoration:none;position:relative}.board-action span{color:var(--yellow)}.board-rule{background:var(--yellow);width:58px;height:3px;position:absolute;top:38px;right:40px;transform:rotate(-4deg)}.board-doodles{opacity:.34;gap:14px;font:2rem Georgia,serif;display:flex;position:absolute;bottom:26px;right:30px;transform:rotate(-8deg)}.event-section{padding:88px 0}.event-section-tint{position:relative}.event-section-tint:before{content:"";z-index:-1;background:var(--paper-deep);position:absolute;inset:24px calc(50% - 50vw)}.section-heading{justify-content:space-between;align-items:end;gap:20px;margin-bottom:30px;display:flex}.section-heading h2,.host-callout h2{color:var(--purple);letter-spacing:-.045em;margin:7px 0 0;font:700 clamp(2.3rem,5vw,4rem)/1 Georgia,serif}.section-count{color:var(--muted);font-size:.84rem}.event-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;display:grid}.event-card{border:1px solid var(--line);background:#fffdf8bd;border-radius:20px;min-height:285px;transition:transform .18s,box-shadow .18s;box-shadow:0 4px 16px #34246d0a}.event-card:hover{box-shadow:var(--shadow);transform:translateY(-5px)rotate(-.3deg)}.event-card.is-live{border-color:#f56f5d73}.event-card>a{flex-direction:column;height:100%;padding:26px;text-decoration:none;display:flex}.event-card-top{justify-content:space-between;align-items:center;gap:12px;display:flex}.status-pill,.topic{width:-moz-fit-content;width:fit-content;min-height:25px;color:var(--purple);letter-spacing:.06em;text-transform:uppercase;background:#34246d14;border-radius:999px;align-items:center;padding:3px 10px;font-size:.69rem;font-weight:850;display:inline-flex}.status-pill.status-live{color:#a42d25;background:#ffe1db}.status-pill.status-live:before{content:"";background:var(--coral);border-radius:50%;width:6px;height:6px;margin-right:6px;animation:1.8s infinite pulse}.topic{color:var(--green);background:#217a5317}.topic-large{margin-top:18px}.event-card h3{color:var(--ink);letter-spacing:-.025em;margin:28px 0 11px;font:700 1.55rem/1.14 Georgia,serif}.event-card p{color:var(--muted);-webkit-line-clamp:3;-webkit-box-orient:vertical;margin:0;line-height:1.55;display:-webkit-box;overflow:hidden}.event-link{color:var(--purple);justify-content:space-between;margin-top:auto;padding-top:24px;font-size:.86rem;font-weight:800;display:flex}.empty-card{text-align:center;border:1px dashed #34246d40;border-radius:20px;grid-column:1/-1;padding:55px 30px}.empty-card>span{font-size:2rem}.empty-card h3{margin:12px 0 6px;font:700 1.5rem Georgia,serif}.empty-card p,.empty-line{color:var(--muted)}.upcoming-list{border-top:1px solid var(--line)}.upcoming-row{border-bottom:1px solid var(--line);grid-template-columns:minmax(130px,.34fr) 1fr 34px;align-items:center;gap:25px;min-height:92px;padding:16px 8px;text-decoration:none;display:grid}.upcoming-row:hover strong,.upcoming-row:hover .row-arrow{color:var(--coral)}.upcoming-row time{color:var(--purple);font-size:.83rem;font-weight:780}.upcoming-row>span:nth-child(2){flex-direction:column;gap:6px;display:flex}.upcoming-row strong{font:650 1.22rem Georgia,serif;transition:color .16s}.upcoming-row small{color:var(--muted)}.row-arrow{color:var(--purple);font-size:1.3rem}.host-callout{color:#fff;background:var(--purple);border-radius:28px;margin:90px 0 100px;padding:clamp(42px,7vw,78px);position:relative;overflow:hidden}.host-callout:after{content:"?";color:#ffffff0f;font:500 25rem Georgia,serif;position:absolute;top:-32%;right:5%;transform:rotate(12deg)}.host-callout .eyebrow{color:var(--yellow)}.host-callout h2{z-index:1;color:#fff;position:relative}.host-callout p{z-index:1;opacity:.75;max-width:520px;line-height:1.6;position:relative}.host-callout .button{z-index:1;margin-top:16px;position:relative}.site-footer{min-height:100px;color:var(--muted);border-top:1px solid var(--line);justify-content:space-between;align-items:center;font-size:.82rem;display:flex}.site-footer a{color:var(--purple);font-weight:750}.dialog{width:min(480px,100% - 28px);color:var(--ink);background:var(--white);border:0;border-radius:24px;padding:38px;box-shadow:0 24px 90px #201a324d}.dialog::backdrop{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background:#201a3294}.dialog h2{color:var(--purple);margin:7px 0 10px;font:700 2.2rem Georgia,serif}.dialog>p{color:var(--muted);margin-top:0;line-height:1.5}.dialog-wide{width:min(650px,100% - 28px)}.dialog-close{width:35px;height:35px;color:var(--muted);cursor:pointer;background:#34246d12;border:0;border-radius:50%;font-size:1.4rem;position:absolute;top:14px;right:18px}.form-stack{gap:17px;margin-top:28px;display:grid}.form-stack.compact{gap:12px;margin-top:0}.form-stack label{color:var(--ink);gap:7px;font-size:.76rem;font-weight:790;display:grid}.form-stack input,.form-stack select,.form-stack textarea,.inline-form input,.share-box input{border:1px solid var(--line);width:100%;min-height:46px;color:var(--ink);background:#fff;border-radius:11px;outline:none;padding:10px 13px}.form-stack textarea{resize:vertical}.form-stack input:focus,.form-stack select:focus,.form-stack textarea:focus,.inline-form input:focus{border-color:var(--purple-2);box-shadow:0 0 0 3px #5b43a41f}.form-stack>.button{width:100%;margin-top:4px}.form-stack.compact>.button{width:-moz-fit-content;width:fit-content}.form-note,.form-error{min-height:1.2em;color:var(--muted);overflow-wrap:anywhere;margin:0;font-size:.76rem}.form-error{color:#a42d25}.form-note:empty,.form-error:empty{display:none}.form-checks{color:var(--muted);flex-wrap:wrap;gap:8px 18px;padding:3px 0;font-size:.8rem;display:flex}.form-stack .form-checks label{cursor:pointer;align-items:center;gap:7px;font-weight:600;display:flex}.form-stack .form-checks input{width:16px;height:16px;min-height:0;accent-color:var(--purple);margin:0;padding:0}.form-stack .form-checks label:has(input:checked){color:var(--purple)}.form-pair{grid-template-columns:1fr 1fr;gap:14px;display:grid}.text-button{width:-moz-fit-content;width:fit-content;color:var(--purple);cursor:pointer;background:0 0;border:0;border-bottom:1px solid #0000;padding:0;font-weight:750}.text-button:hover{border-bottom-color:currentColor}.event-shell{padding:42px 0 100px}.back-link{color:var(--muted);margin-bottom:28px;font-size:.84rem;font-weight:700;text-decoration:none;display:inline-block}.back-link:hover{color:var(--purple)}.event-layout{grid-template-columns:minmax(0,1.55fr) minmax(280px,.75fr);align-items:start;gap:18px;display:grid}.region{gap:18px;min-width:0;display:grid}.region-sidebar,.region-bottom,.region-drawer{grid-column:1/-1;grid-template-columns:repeat(auto-fit,minmax(270px,1fr))}.event-panel{border:1px solid var(--line);background:#fffdf8d1;border-radius:20px;min-width:0;padding:28px;position:relative;box-shadow:0 4px 20px #34246d0a}.panel-title{color:var(--muted);letter-spacing:.1em;text-transform:uppercase;margin-bottom:18px;font-size:.68rem;font-weight:850;display:block}.panel-event-header{color:#fff;background:var(--purple);border:0;align-items:end;min-height:320px;padding:clamp(34px,6vw,62px);display:flex;overflow:hidden}.panel-event-header:after{content:"✦";color:#ffffff12;font-size:15rem;position:absolute;top:-80px;right:-20px;transform:rotate(18deg)}.panel-event-header .panel-title{display:none}.event-heading{z-index:1;position:relative}.event-heading .topic{color:#225b42;background:#cbe9ce;margin-left:8px}.event-heading h1{letter-spacing:-.055em;max-width:780px;margin:24px 0 15px;font:700 clamp(2.7rem,7vw,5.4rem)/.95 Georgia,serif}.event-heading p{color:#ffffffad;max-width:680px;margin:0;font-size:1.03rem;line-height:1.6}.listen-card{grid-template-columns:150px 1fr;align-items:center;gap:30px;min-height:245px;display:grid}.listen-card h2,.stage-card h2{color:var(--purple);margin:7px 0 10px;font:700 2.2rem Georgia,serif}.listen-card p,.stage-card p{color:var(--muted);margin:0 0 20px;line-height:1.55}.listen-card audio{width:100%;margin-top:18px;display:none}.listen-card audio:not([src=""]){display:block}.sound-orbit{background:#ffe3dc;border-radius:50%;place-items:center;width:135px;height:135px;display:grid;position:relative}.sound-orbit b{z-index:2;color:#fff;background:var(--coral);border-radius:50%;place-items:center;width:60px;height:60px;padding-left:4px;font-size:1.25rem;display:grid}.sound-orbit span{border:1px solid #f56f5d59;border-radius:50%;animation:2.6s ease-out infinite sound;position:absolute;inset:18px}.sound-orbit span:nth-child(2){animation-delay:.8s}.sound-orbit span:nth-child(3){animation-delay:1.6s}.waiting .sound-orbit span{animation-play-state:paused}.host-line,.join-line,.people-row,.control-line{align-items:center;gap:14px;display:flex}.host-line small,.people-row small{color:var(--muted);display:block}.host-line strong,.people-row strong{margin-top:2px;display:block}.avatar,.stage-avatar{color:#fff;background:var(--purple);border-radius:14px 14px 14px 4px;flex:none;place-items:center;width:47px;height:47px;font:700 1.2rem Georgia,serif;display:grid}.avatar-small{border-radius:10px 10px 10px 3px;width:32px;height:32px;font-size:.82rem}.reading-copy{color:var(--muted);margin:0;font:1.05rem/1.7 Georgia,serif}.join-line{justify-content:space-between}.join-line strong{color:var(--purple)}.join-line p{color:var(--muted);margin:4px 0 0;font-size:.82rem}.panel-empty,.muted{color:var(--muted);line-height:1.55}.metric{flex-direction:column;gap:5px;display:flex}.metric strong{color:var(--purple);font:700 1.65rem Georgia,serif}.metric span{color:var(--muted);font-size:.78rem}.stage-card{align-items:center;gap:22px;display:flex}.stage-avatar{width:88px;height:88px;color:var(--purple);background:var(--yellow);border-radius:24px 24px 24px 7px;font-size:2.2rem}.control-stack{flex-wrap:wrap;gap:10px;display:flex}.control-line{justify-content:space-between}.status-dot{background:#b9b2bd;border-radius:50%;width:10px;height:10px}.status-dot.on{background:var(--coral);box-shadow:0 0 0 5px #f56f5d26}.share-box,.inline-form{gap:8px;display:flex}.share-box input{min-width:0;color:var(--muted);background:var(--paper)}.inline-form{margin-top:15px}.inline-form input{min-width:0}.chat-messages{gap:13px;min-height:110px;max-height:300px;display:grid;overflow-y:auto}.chat-message{align-items:start;gap:10px;display:flex}.chat-message p{gap:3px;margin:0;display:grid}.chat-message strong{font-size:.76rem}.chat-message span{color:var(--muted);line-height:1.45}.hand-raises{gap:10px;display:grid}.raise-row{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:12px;padding-bottom:10px;display:flex}.raise-row>span{gap:2px;display:grid}.raise-row small{color:var(--muted);text-transform:capitalize}.raise-row>div{gap:10px;display:flex}.panel-failed{border-color:#f56f5d66}@keyframes pulse{50%{opacity:.42;transform:scale(.8)}}@keyframes sound{0%{opacity:.8;transform:scale(.72)}to{opacity:0;transform:scale(1.45)}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media (max-width:900px){.hero{grid-template-columns:1fr;padding-top:70px}.hero-board{width:min(560px,92%);margin-inline:auto}.event-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.event-layout{grid-template-columns:1fr}.region-secondary{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}}@media (max-width:640px){.site-header,main,.site-footer{width:min(100% - 24px,1180px)}.site-header{min-height:70px}.site-nav a{display:none}.wordmark{font-size:1rem}.hero{gap:55px;min-height:auto;padding:60px 0}.hero h1{font-size:clamp(3.5rem,20vw,5.5rem)}.hero-actions{display:grid}.hero-actions .button{width:100%}.hero-board{border-width:8px;width:96%;min-height:340px;padding:48px 29px 38px}.event-section{padding:64px 0}.event-grid{grid-template-columns:1fr}.upcoming-row{grid-template-columns:1fr 26px;gap:9px 14px;padding-block:20px}.upcoming-row time,.upcoming-row>span:nth-child(2){grid-column:1}.upcoming-row .row-arrow{grid-area:1/2/3}.host-callout{margin:64px 0;padding:42px 28px}.site-footer{flex-direction:column;justify-content:center;align-items:start;gap:6px;min-height:90px}.dialog{padding:34px 22px 24px}.form-pair{grid-template-columns:1fr}.event-shell{padding-top:28px}.event-panel{border-radius:16px;padding:22px}.panel-event-header{min-height:390px;padding:32px 23px}.event-heading h1{font-size:clamp(2.65rem,15vw,4.2rem)}.listen-card{grid-template-columns:1fr}.sound-orbit{width:108px;height:108px}.sound-orbit b{width:52px;height:52px}.join-line,.share-box,.inline-form{flex-direction:column;align-items:stretch}.join-line .button,.share-box .button,.inline-form .button{width:100%}.region-secondary,.region-sidebar,.region-bottom,.region-drawer{grid-template-columns:1fr}}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=class extends Error{status;constructor(e,t){super(e),this.status=t}};async function t(t,n={}){let r=await fetch(t,{credentials:`same-origin`,...n,headers:{...n.body?{"content-type":`application/json`}:{},...n.headers}}),i={};try{i=await r.json()}catch{i={}}if(!r.ok)throw new e(String((i&&typeof i==`object`?i:{}).error??`That did not work.`),r.status);return i}function n(e,n,r=`POST`){return t(e,{method:r,body:JSON.stringify(n)})}var r=document.querySelector(`#main`),i=document.querySelector(`#account-button`),a=document.querySelector(`#account-dialog`),o=document.querySelector(`#account-form`),s=document.querySelector(`#account-title`),c=document.querySelector(`#account-copy`),l=document.querySelector(`#account-error`),u=document.querySelector(`#account-mode`),d=document.querySelector(`#event-dialog`),f=document.querySelector(`#event-form`),p=document.querySelector(`#event-form-title`),m=document.querySelector(`#event-kicker`),h=document.querySelector(`#event-submit`),g=document.querySelector(`#event-error`),_=document.querySelector(`#schedule-fields`),v=null,y=!1,b=!1,x=null,S=null,C=null,w=null;function T(e){return String(e??``).replaceAll(`&`,`&`).replaceAll(`<`,`<`).replaceAll(`>`,`>`).replaceAll(`"`,`"`).replaceAll(`'`,`'`)}function E(e){return`/live/${encodeURIComponent(e.slug)}`}function D(){let e=new URLSearchParams(location.search).get(`invite`);return e?`?invite=${encodeURIComponent(e)}`:``}function O(e,t={}){return e?new Intl.DateTimeFormat(void 0,{weekday:`short`,month:`short`,day:`numeric`,hour:`numeric`,minute:`2-digit`,...t}).format(new Date(e)):`Time to be announced`}function k(e){return e.status===`live`?`Live now`:e.status===`ended`?e.recordingId?`Replay available`:`Event ended`:e.status===`cancelled`?`Cancelled`:O(e.startsAt)}function A(e){return C?.permissions.includes(e)??!1}function j(){i.textContent=v?v.email.split(`@`)[0]||`Account`:`Sign in`,i.classList.toggle(`signed-in`,!!v)}async function M(){try{v=(await t(`/api/v1/auth/me`)).account}catch{v=null}j()}function N(e){if(v){a.showModal(),s.textContent=`Signed in as ${v.email}`,c.textContent=`Your BackToSchool identity is your NixAmp account.`,o.hidden=!0;return}x=e??null,o.hidden=!1,l.textContent=``,P(),a.showModal()}function P(){s.textContent=y?`Create your account`:`Welcome back`,c.textContent=y?`One NixAmp account works here and everywhere NixAmp goes.`:`Sign in to host, chat, or raise your hand.`,u.textContent=y?`Already have an account? Sign in`:`New here? Create an account`;let e=o.elements.namedItem(`password`);e.autocomplete=y?`new-password`:`current-password`,o.querySelector(`button[type="submit"]`).textContent=y?`Create account`:`Sign in`}function F(e){if(!v){N(()=>F(e));return}b=e===`scheduled`,_.hidden=!b,p.textContent=b?`Schedule a live`:`Go live`,m.textContent=b?`Put it on the calendar`:`Start a conversation`,h.textContent=b?`Schedule live`:`Create live`,g.textContent=``;let t=f.elements.namedItem(`startsAt`);if(t.required=b,b&&!t.value){let e=new Date(Date.now()+36e5);e.setMinutes(Math.ceil(e.getMinutes()/15)*15,0,0),t.value=`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}T${String(e.getHours()).padStart(2,`0`)}:${String(e.getMinutes()).padStart(2,`0`)}`}d.showModal()}function I(e){history.pushState(null,``,e),Z()}function L(e){let t=e.status===`live`||e.status===`starting`;return`
|
|
2
|
+
<article class="event-card ${t?`is-live`:``}">
|
|
3
|
+
<a href="${E(e)}" data-link>
|
|
4
|
+
<div class="event-card-top">
|
|
5
|
+
<span class="status-pill ${t?`status-live`:``}">${t?`Live`:T(k(e))}</span>
|
|
6
|
+
${e.topic?`<span class="topic">${T(e.topic)}</span>`:``}
|
|
7
|
+
</div>
|
|
8
|
+
<h3>${T(e.title)}</h3>
|
|
9
|
+
<p>${T(e.description||`Drop in and learn something with us.`)}</p>
|
|
10
|
+
<span class="event-link">${t?`Listen live`:`See event`}<span aria-hidden="true">→</span></span>
|
|
11
|
+
</a>
|
|
12
|
+
</article>`}async function R(){document.title=`BackToSchool.help — Learn something live`;let{events:e}=await t(`/api/v1/events?limit=60`).catch(()=>({events:[]})),n=e.filter(e=>e.status===`live`||e.status===`starting`),i=e.filter(e=>e.status===`scheduled`||e.status===`draft`),a=e.filter(e=>e.status===`ended`&&e.recordingId);r.innerHTML=`
|
|
13
|
+
<section class="hero">
|
|
14
|
+
<div class="hero-copy">
|
|
15
|
+
<span class="eyebrow">Go back to school whenever you want</span>
|
|
16
|
+
<h1>Learn something<br /><em>live.</em></h1>
|
|
17
|
+
<p>Drop into thoughtful conversations, ask real questions, or start a room about something you know.</p>
|
|
18
|
+
<div class="hero-actions">
|
|
19
|
+
<button class="button button-primary" type="button" data-event-mode="live"><span class="live-dot"></span> Go live</button>
|
|
20
|
+
<button class="button button-secondary" type="button" data-event-mode="scheduled">Schedule live</button>
|
|
21
|
+
</div>
|
|
22
|
+
<p class="hero-note">Listening is always one tap away. No account required.</p>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="hero-board" aria-label="What is happening today">
|
|
25
|
+
<span class="tape tape-one" aria-hidden="true"></span>
|
|
26
|
+
<span class="tape tape-two" aria-hidden="true"></span>
|
|
27
|
+
<div class="board-rule"></div>
|
|
28
|
+
<span class="board-label">Today’s lesson</span>
|
|
29
|
+
<strong>${T(n[0]?.title??i[0]?.title??`The best teachers are curious people`)}</strong>
|
|
30
|
+
<p>${n[0]?`Happening right now`:i[0]?k(i[0]):`Host the first live conversation`}</p>
|
|
31
|
+
${n[0]?`<a href="${E(n[0])}" data-link class="board-action">Listen now <span>↗</span></a>`:`<button type="button" data-event-mode="live" class="board-action">Start a room <span>↗</span></button>`}
|
|
32
|
+
<div class="board-doodles" aria-hidden="true"><span>?</span><span>✦</span><span>≈</span></div>
|
|
33
|
+
</div>
|
|
34
|
+
</section>
|
|
35
|
+
|
|
36
|
+
<section class="event-section" id="live">
|
|
37
|
+
<div class="section-heading">
|
|
38
|
+
<div><span class="eyebrow">Walk in anytime</span><h2>Live now</h2></div>
|
|
39
|
+
<span class="section-count">${n.length} ${n.length===1?`room`:`rooms`}</span>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="event-grid">
|
|
42
|
+
${n.length?n.map(L).join(``):`<div class="empty-card"><span>☕</span><h3>The halls are quiet.</h3><p>Start a live and give people something worth dropping into.</p><button class="text-button" data-event-mode="live">Open a room →</button></div>`}
|
|
43
|
+
</div>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<section class="event-section event-section-tint" id="upcoming">
|
|
47
|
+
<div class="section-heading">
|
|
48
|
+
<div><span class="eyebrow">Save your seat</span><h2>Coming up</h2></div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="upcoming-list">
|
|
51
|
+
${i.length?i.map(e=>`
|
|
52
|
+
<a class="upcoming-row" href="${E(e)}" data-link>
|
|
53
|
+
<time>${T(O(e.startsAt,{weekday:`short`,month:`short`,day:`numeric`}))}</time>
|
|
54
|
+
<span><strong>${T(e.title)}</strong><small>${T(e.topic||`Live conversation`)}</small></span>
|
|
55
|
+
<span class="row-arrow">→</span>
|
|
56
|
+
</a>`).join(``):`<p class="empty-line">Nothing scheduled yet. Your idea could be next.</p>`}
|
|
57
|
+
</div>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
${a.length?`<section class="event-section"><div class="section-heading"><div><span class="eyebrow">Listen after class</span><h2>Recent replays</h2></div></div><div class="event-grid">${a.map(L).join(``)}</div></section>`:``}
|
|
61
|
+
|
|
62
|
+
<section class="host-callout">
|
|
63
|
+
<span class="eyebrow">Know a thing or two?</span>
|
|
64
|
+
<h2>Someone wants to hear it.</h2>
|
|
65
|
+
<p>You don’t need a studio or a syllabus. Bring an idea and start talking.</p>
|
|
66
|
+
<button class="button button-light" type="button" data-event-mode="scheduled">Plan a conversation</button>
|
|
67
|
+
</section>`}function z(e){let t=D(),n=e.recordingId&&e.status===`ended`?`/api/v1/recordings/${encodeURIComponent(e.recordingId)}`:`/api/channels/${encodeURIComponent(e.roomId)}${t}`,r=e.status===`live`||!!e.recordingId;return`
|
|
68
|
+
<div class="listen-card ${r?``:`waiting`}">
|
|
69
|
+
<div class="sound-orbit" aria-hidden="true"><span></span><span></span><span></span><b>▶</b></div>
|
|
70
|
+
<div>
|
|
71
|
+
<span class="eyebrow">${e.status===`live`?`On air now`:e.recordingId?`Replay`:`The room opens soon`}</span>
|
|
72
|
+
<h2>${r?e.status===`live`?`Listen live`:`Listen again`:`We’ll see you here.`}</h2>
|
|
73
|
+
<p id="player-note">${r?`Press play and you’re in. No sign-up, no setup.`:T(k(e))}</p>
|
|
74
|
+
${r?`<button class="button button-primary listen-button" type="button" data-listen><span class="live-dot"></span> ${e.status===`live`?`Listen live`:`Play replay`}</button><audio id="event-audio" preload="none" src="${T(n)}" controls></audio>`:``}
|
|
75
|
+
</div>
|
|
76
|
+
</div>`}function B(e){return e.chatEnabled?`
|
|
77
|
+
<div id="chat-messages" class="chat-messages"><p class="muted">Loading the conversation…</p></div>
|
|
78
|
+
${v?`<form id="chat-form" class="inline-form"><input name="body" maxlength="1000" placeholder="Add to the conversation" aria-label="Chat message" required /><button class="button button-small" type="submit">Send</button></form>`:`<button class="text-button" type="button" data-sign-in>Sign in to join the chat →</button>`}`:`<div class="panel-empty">Chat is off for this event.</div>`}function V(e,t){switch(e.type){case`event-header`:return`<div class="event-heading"><span class="status-pill ${t.status===`live`?`status-live`:``}">${T(k(t))}</span>${t.topic?`<span class="topic">${T(t.topic)}</span>`:``}<h1>${T(t.title)}</h1><p>${T(t.description||`A live conversation powered by NixAmp.`)}</p></div>`;case`player`:return z(t);case`stage`:return`<div class="stage-card"><span class="stage-avatar">${T(t.title.slice(0,1).toUpperCase())}</span><div><span class="eyebrow">Host stage</span><h2>${T(t.title)}</h2><p id="broadcast-note">${t.status===`live`?`This event is live.`:`Start when you’re ready.`}</p></div></div>`;case`host`:return`<div class="host-line"><span class="avatar">N</span><div><small>Hosted with</small><strong>NixAmp Live</strong></div></div>`;case`about`:return`<p class="reading-copy">${T(t.description||`Come listen, learn, and ask a question live.`)}</p>${t.topic?`<span class="topic topic-large">${T(t.topic)}</span>`:``}`;case`join`:return v?`<p class="panel-empty">You’re signed in and ready to participate.</p>`:`<div class="join-line"><div><strong>Want to ask something?</strong><p>Join with your NixAmp account.</p></div><button class="button button-secondary" type="button" data-sign-in>Join in</button></div>`;case`chat`:return B(t);case`questions`:return`<div class="panel-empty">Questions shared in chat can be brought onto the stage.</div>`;case`resources`:return`<div class="panel-empty">The host hasn’t added resources yet.</div>`;case`participants`:return`<div class="metric"><strong id="listener-count">—</strong><span>listening now</span></div>`;case`speakers`:return`<div class="people-row"><span class="avatar">H</span><span><strong>Host</strong><small>On stage</small></span></div>`;case`raise-hand`:return t.handRaiseEnabled?`<div class="join-line"><div><strong>Have something to add?</strong><p>Let the host know you’d like to speak.</p></div><button class="button button-secondary" type="button" data-raise-hand>Raise hand</button></div>`:null;case`hand-raises`:return`<div id="hand-raises" class="hand-raises"><p class="muted">No hands raised.</p></div>`;case`invite`:return`<form id="invite-form" class="form-stack compact"><label>Email<input name="email" type="email" placeholder="someone@example.com" required /></label><label>Invite as<select name="role"><option value="listener">Listener</option><option value="speaker">Speaker</option><option value="moderator">Moderator</option></select></label><button class="button button-small" type="submit">Create invite</button><p id="invite-note" class="form-note"></p></form>`;case`share`:return`<div class="share-box"><input id="share-url" value="${T(location.href)}" readonly aria-label="Event URL" /><button class="button button-small" type="button" data-copy>Copy link</button></div>`;case`event-controls`:return`<div class="control-stack"><button class="button button-primary" type="button" data-broadcast>${t.status===`live`?`Use this microphone`:`Start live audio`}</button>${t.status===`live`?`<button class="button button-danger" type="button" data-end-event>End event</button>`:``}</div>`;case`schedule`:return`<div class="metric"><strong>${T(O(t.startsAt))}</strong><span>${T(t.timezone)}</span></div>`;case`recording`:return`<div class="control-line"><span>${t.recordingEnabled?`Recording requested`:`Recording is off`}</span><span class="status-dot ${t.recordingEnabled?`on`:``}"></span></div>`;default:return null}}function H(e,t){try{if(!e.enabled||!e.visible||(e.permissions??[]).some(e=>!A(e)))return``;let n=V(e,t);if(n===null)return``;let r=e.title||e.type.replaceAll(`-`,` `);return`<section class="event-panel panel-${T(e.type)}" data-panel="${T(e.id)}"><span class="panel-title">${T(r)}</span>${n}</section>`}catch{return`<section class="event-panel panel-failed"><span class="panel-title">Panel unavailable</span><p>This part didn’t load. The audio player is unaffected.</p></section>`}}async function U(e){let n=await t(`/api/v1/events/${encodeURIComponent(e)}${D()}`);C=n;let i=n.event;document.title=`${i.title} — BackToSchool.help`,document.querySelector(`meta[name="description"]`)?.setAttribute(`content`,i.description||`Listen to ${i.title} live on BackToSchool.help.`),document.querySelector(`meta[property="og:title"]`)?.setAttribute(`content`,i.title),document.querySelector(`meta[property="og:description"]`)?.setAttribute(`content`,i.description||`Learn something live.`);let a=new Map;for(let e of[...n.layout.panels].sort((e,t)=>e.order-t.order)){let t=H(e,i);if(!t)continue;let n=a.get(e.region)??[];n.push(t),a.set(e.region,n)}r.innerHTML=`
|
|
79
|
+
<div class="event-shell">
|
|
80
|
+
<a class="back-link" href="/" data-link>← Explore more lives</a>
|
|
81
|
+
<div class="event-layout">
|
|
82
|
+
<div class="region region-primary">${(a.get(`primary`)??[]).join(``)}</div>
|
|
83
|
+
<aside class="region region-secondary">${(a.get(`secondary`)??[]).join(``)}</aside>
|
|
84
|
+
${(a.get(`sidebar`)??[]).length?`<aside class="region region-sidebar">${a.get(`sidebar`).join(``)}</aside>`:``}
|
|
85
|
+
${(a.get(`bottom`)??[]).length?`<div class="region region-bottom">${a.get(`bottom`).join(``)}</div>`:``}
|
|
86
|
+
${(a.get(`drawer`)??[]).length?`<div class="region region-drawer">${a.get(`drawer`).join(``)}</div>`:``}
|
|
87
|
+
</div>
|
|
88
|
+
</div>`,W(i)}function W(r){document.querySelector(`[data-listen]`)?.addEventListener(`click`,async e=>{let t=e.currentTarget,n=document.querySelector(`#event-audio`);if(n){t.disabled=!0,t.textContent=`Connecting…`;try{await n.play(),t.textContent=`You’re listening`,document.querySelector(`#player-note`).textContent=`Live audio from this NixAmp room.`}catch{t.disabled=!1,t.textContent=`Try listening again`,document.querySelector(`#player-note`).textContent=`The host may still be getting ready. Try again in a moment.`}}});let i=document.querySelector(`#chat-form`);i?.addEventListener(`submit`,e=>{e.preventDefault();let t=i.elements.namedItem(`body`),a=t.value.trim();a&&(t.value=``,n(`/api/v1/events/${encodeURIComponent(r.id)}/chat`,{body:a}).then(()=>G(r)).catch(e=>{t.placeholder=e instanceof Error?e.message:`Could not send`}))});let a=document.querySelector(`#invite-form`);if(a?.addEventListener(`submit`,e=>{e.preventDefault();let n=new FormData(a),i=document.querySelector(`#invite-note`);i.textContent=`Creating invite…`,t(`/api/v1/events/${encodeURIComponent(r.id)}/invitations`,{method:`POST`,body:JSON.stringify({email:n.get(`email`),role:n.get(`role`)})}).then(async({inviteUrl:e,sent:t})=>{await navigator.clipboard?.writeText(e).catch(()=>void 0),i.textContent=t?`Invite sent. The link is copied too.`:`Invite link copied: ${e}`,a.reset()}).catch(e=>{i.textContent=e instanceof Error?e.message:`Could not invite`})}),document.querySelector(`[data-raise-hand]`)?.addEventListener(`click`,t=>{let i=t.currentTarget;i.disabled=!0,n(`/api/v1/events/${encodeURIComponent(r.id)}/hand-raises`,{}).then(()=>{i.textContent=`Hand raised ✓`}).catch(t=>{i.disabled=!1,i.textContent=t instanceof e&&t.status===401?`Sign in to raise hand`:`Try again`})}),document.querySelector(`[data-broadcast]`)?.addEventListener(`click`,()=>void J()),document.querySelector(`[data-end-event]`)?.addEventListener(`click`,()=>void X()),document.querySelector(`[data-copy]`)?.addEventListener(`click`,async e=>{await navigator.clipboard.writeText(location.href),e.currentTarget.textContent=`Copied ✓`}),r.chatEnabled){G(r);let e=window.setInterval(()=>void G(r),4e3),t=A(`event.moderate`)?window.setInterval(()=>void K(r),3e3):0;K(r),S=()=>{clearInterval(e),t&&clearInterval(t)}}q(r)}async function G(e){let n=document.querySelector(`#chat-messages`);if(n)try{let{messages:r}=await t(`/api/v1/events/${encodeURIComponent(e.id)}/chat${D()}`);n.innerHTML=r.length?r.map(e=>`<div class="chat-message"><span class="avatar avatar-small">${T(e.authorName.slice(0,1).toUpperCase())}</span><p><strong>${T(e.authorName)}</strong><span>${T(e.body)}</span></p></div>`).join(``):`<p class="muted">No messages yet. Say hello when you’re ready.</p>`,n.scrollTop=n.scrollHeight}catch{n.innerHTML=`<p class="muted">Chat is taking a break. The audio still works.</p>`}}async function K(e){let n=document.querySelector(`#hand-raises`);if(n&&A(`event.moderate`))try{let{handRaises:r}=await t(`/api/v1/events/${encodeURIComponent(e.id)}/hand-raises`);n.innerHTML=r.length?r.map(e=>`<div class="raise-row"><span><strong>${T(e.displayName)}</strong><small>${T(e.state)}</small></span><div><button class="text-button" data-hand="${T(e.accountId)}" data-state="invited">Invite</button><button class="text-button" data-hand="${T(e.accountId)}" data-state="dismissed">Dismiss</button></div></div>`).join(``):`<p class="muted">No hands raised.</p>`}catch{n.innerHTML=`<p class="muted">Could not refresh hand raises.</p>`}}async function q(e){let n=document.querySelector(`#listener-count`);if(n)try{let{channels:r}=await t(`/api/channels`);n.textContent=String(r.find(t=>t.id===e.roomId)?.listeners??0)}catch{n.textContent=`0`}}async function J(){if(!C||w)return;let e=document.querySelector(`[data-broadcast]`),n=document.querySelector(`#broadcast-note`);try{e?.setAttribute(`disabled`,`true`),n&&(n.textContent=`Asking for your microphone…`);let r=C.event;if(r.status!==`live`){let e=await t(`/api/v1/events/${encodeURIComponent(r.id)}/start`,{method:`POST`,body:JSON.stringify({version:r.version})});C=e,r=e.event}let i=await navigator.mediaDevices.getUserMedia({audio:!0}),a=[`audio/webm;codecs=opus`,`audio/webm`,`audio/ogg;codecs=opus`].find(e=>MediaRecorder.isTypeSupported(e))??``,o=new MediaRecorder(i,a?{mimeType:a}:void 0),s={recorder:o,stream:i,queue:Promise.resolve()};w=s,o.addEventListener(`dataavailable`,e=>{e.data.size&&(s.queue=s.queue.then(async()=>{if(!(await fetch(`/api/channels/${encodeURIComponent(r.roomId)}/chunk?format=${encodeURIComponent(o.mimeType||`webm`)}&name=${encodeURIComponent(r.title)}`,{method:`POST`,credentials:`same-origin`,headers:{"content-type":o.mimeType||`audio/webm`},body:e.data})).ok)throw Error(`NixAmp could not publish this audio`)}))}),o.start(1e3),e&&(e.disabled=!1,e.textContent=`Stop this microphone`,e.dataset.stopBroadcast=`true`,e.onclick=()=>void Y()),n&&(n.textContent=`Your microphone is live through NixAmp.`)}catch(t){e&&(e.disabled=!1),n&&(n.textContent=t instanceof Error?t.message:`Could not start the microphone.`)}}async function Y(){let e=w;if(!e)return;w=null;let t=new Promise(t=>e.recorder.addEventListener(`stop`,()=>t(),{once:!0}));e.recorder.stop(),e.stream.getTracks().forEach(e=>e.stop()),await t,await e.queue.catch(()=>void 0);let n=document.querySelector(`#broadcast-note`);n&&(n.textContent=`This microphone is off. The event is still open.`);let r=document.querySelector(`[data-broadcast]`);r&&(r.textContent=`Use this microphone`,r.onclick=()=>void J())}async function X(){if(!C)return;await Y();let e=C.event;try{C=await t(`/api/v1/events/${encodeURIComponent(e.id)}/end`,{method:`POST`,body:JSON.stringify({version:e.version})}),await fetch(`/api/channels/${encodeURIComponent(e.roomId)}`,{method:`DELETE`,credentials:`same-origin`}),await U(e.slug)}catch(e){let t=document.querySelector(`#broadcast-note`);t&&(t.textContent=e instanceof Error?e.message:`Could not end the event.`)}}async function Z(){S?.(),S=null,C=null;let t=/^\/live\/([^/]+)\/?$/.exec(location.pathname);try{t?await U(decodeURIComponent(t[1])):await R()}catch(t){r.innerHTML=`<section class="error-page"><span class="eyebrow">Class dismissed?</span><h1>${T(t instanceof e&&t.status===404?`That live event isn’t available.`:`We couldn’t open this page.`)}</h1><p>${T(t instanceof Error?t.message:`Try again in a moment.`)}</p><a href="/" data-link class="button button-primary">Back to live events</a></section>`}r.focus({preventScroll:!0})}i.addEventListener(`click`,()=>{if(!v){N();return}confirm(`Signed in as ${v.email}. Sign out?`)&&t(`/api/v1/auth/logout`,{method:`POST`}).finally(()=>{v=null,j(),Z()})}),u.addEventListener(`click`,()=>{y=!y,l.textContent=``,P()}),o.addEventListener(`submit`,e=>{e.preventDefault();let n=new FormData(o),r=o.querySelector(`button[type="submit"]`);r.disabled=!0,l.textContent=``,t(`/api/v1/auth/${y?`signup`:`login`}`,{method:`POST`,body:JSON.stringify({email:n.get(`email`),password:n.get(`password`)})}).then(e=>{v=e.account,j(),o.reset(),a.close();let t=x;x=null,t?t():Z()}).catch(e=>{l.textContent=e instanceof Error?e.message:`That did not work.`}).finally(()=>{r.disabled=!1})}),f.addEventListener(`submit`,e=>{e.preventDefault();let n=new FormData(f);h.disabled=!0,g.textContent=``;let r=String(n.get(`startsAt`)??``),i={title:n.get(`title`),description:n.get(`description`),topic:n.get(`topic`),visibility:n.get(`visibility`),chatEnabled:n.get(`chatEnabled`)===`on`,handRaiseEnabled:n.get(`handRaiseEnabled`)===`on`,recordingEnabled:n.get(`recordingEnabled`)===`on`,...b?{startsAt:new Date(r).toISOString(),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone||`UTC`,expectedDurationMinutes:Number(n.get(`expectedDurationMinutes`))}:{}};t(`/api/v1/events`,{method:`POST`,body:JSON.stringify(i)}).then(async e=>{let n=e;b||(n=await t(`/api/v1/events/${encodeURIComponent(e.event.id)}/start`,{method:`POST`,body:JSON.stringify({version:e.event.version})})),d.close(),f.reset(),I(E(n.event))}).catch(e=>{g.textContent=e instanceof Error?e.message:`Could not create the event.`}).finally(()=>{h.disabled=!1})}),document.addEventListener(`click`,e=>{let t=e.target,r=t.closest(`a[data-link]`);if(r&&r.origin===location.origin){e.preventDefault(),I(`${r.pathname}${r.search}`);return}let i=t.closest(`[data-event-mode]`)?.dataset.eventMode;(i===`live`||i===`scheduled`)&&F(i),t.closest(`[data-sign-in]`)&&N(()=>void Z());let a=t.closest(`[data-close]`);a&&a.closest(`dialog`)?.close();let o=t.closest(`[data-hand]`);o&&C&&(o.disabled=!0,n(`/api/v1/events/${encodeURIComponent(C.event.id)}/hand-raises/${encodeURIComponent(o.dataset.hand??``)}`,{state:o.dataset.state},`PATCH`).then(()=>K(C.event)).finally(()=>{o.disabled=!1}))}),window.addEventListener(`popstate`,()=>void Z()),window.addEventListener(`beforeunload`,()=>{w?.stream.getTracks().forEach(e=>e.stop())}),M().then(()=>Z());
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
2
|
+
<rect width="512" height="512" rx="112" fill="#34246d"/>
|
|
3
|
+
<path d="M96 190 256 104l160 86-160 86L96 190Z" fill="#fff8ea"/>
|
|
4
|
+
<path d="M148 224v92c0 38 48 70 108 70s108-32 108-70v-92l-108 58-108-58Z" fill="#ff765f"/>
|
|
5
|
+
<path d="M416 194v116" stroke="#ffd465" stroke-width="24" stroke-linecap="round"/>
|
|
6
|
+
<circle cx="416" cy="338" r="20" fill="#ffd465"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<meta name="theme-color" content="#34246d" />
|
|
7
|
+
<meta name="description" content="Learn something live. Listen to public conversations, classes, and talks powered by NixAmp." />
|
|
8
|
+
<meta property="og:site_name" content="BackToSchool.help" />
|
|
9
|
+
<meta property="og:title" content="BackToSchool.help — Learn something live" />
|
|
10
|
+
<meta property="og:description" content="Live conversations with people who know." />
|
|
11
|
+
<meta property="og:type" content="website" />
|
|
12
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
13
|
+
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
|
14
|
+
<title>BackToSchool.help — Learn something live</title>
|
|
15
|
+
<script type="module" crossorigin src="/assets/index-D7Oas1m5.js"></script>
|
|
16
|
+
<link rel="stylesheet" crossorigin href="/assets/index-6vtCMzi1.css">
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<div id="app">
|
|
20
|
+
<header class="site-header">
|
|
21
|
+
<a class="wordmark" href="/" aria-label="BackToSchool.help home">
|
|
22
|
+
<span class="wordmark-mark" aria-hidden="true">B</span>
|
|
23
|
+
<span>BackToSchool<span>.help</span></span>
|
|
24
|
+
</a>
|
|
25
|
+
<nav class="site-nav" aria-label="Main navigation">
|
|
26
|
+
<a href="/#live">Live now</a>
|
|
27
|
+
<a href="/#upcoming">Upcoming</a>
|
|
28
|
+
<button class="button button-quiet" id="account-button" type="button">Sign in</button>
|
|
29
|
+
</nav>
|
|
30
|
+
</header>
|
|
31
|
+
<main id="main" tabindex="-1">
|
|
32
|
+
<section class="loading-page" aria-live="polite">
|
|
33
|
+
<span class="eyebrow">BackToSchool.help</span>
|
|
34
|
+
<h1>Learn something live.</h1>
|
|
35
|
+
<p>Finding today’s conversations…</p>
|
|
36
|
+
</section>
|
|
37
|
+
</main>
|
|
38
|
+
<footer class="site-footer">
|
|
39
|
+
<span>Live audio, made simple.</span>
|
|
40
|
+
<span>Powered by <a href="https://nixamp.com">NixAmp</a></span>
|
|
41
|
+
</footer>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<dialog id="account-dialog" class="dialog">
|
|
45
|
+
<button class="dialog-close" type="button" data-close aria-label="Close">×</button>
|
|
46
|
+
<span class="eyebrow">Your NixAmp account</span>
|
|
47
|
+
<h2 id="account-title">Welcome back</h2>
|
|
48
|
+
<p id="account-copy">Sign in to host, chat, or raise your hand.</p>
|
|
49
|
+
<form id="account-form" class="form-stack">
|
|
50
|
+
<label>Email<input name="email" type="email" autocomplete="email" required /></label>
|
|
51
|
+
<label>Password<input name="password" type="password" autocomplete="current-password" minlength="8" required /></label>
|
|
52
|
+
<p class="form-error" id="account-error" role="alert"></p>
|
|
53
|
+
<button class="button button-primary" type="submit">Sign in</button>
|
|
54
|
+
<button class="text-button" id="account-mode" type="button">New here? Create an account</button>
|
|
55
|
+
</form>
|
|
56
|
+
</dialog>
|
|
57
|
+
|
|
58
|
+
<dialog id="event-dialog" class="dialog dialog-wide">
|
|
59
|
+
<button class="dialog-close" type="button" data-close aria-label="Close">×</button>
|
|
60
|
+
<span class="eyebrow" id="event-kicker">Start a conversation</span>
|
|
61
|
+
<h2 id="event-form-title">Go live</h2>
|
|
62
|
+
<form id="event-form" class="form-stack">
|
|
63
|
+
<label>Title<input name="title" maxlength="160" placeholder="What will people learn?" required /></label>
|
|
64
|
+
<label>Description<textarea name="description" maxlength="5000" rows="3" placeholder="A short introduction for listeners"></textarea></label>
|
|
65
|
+
<div class="form-pair">
|
|
66
|
+
<label>Topic<input name="topic" maxlength="100" placeholder="Technology, music…" /></label>
|
|
67
|
+
<label>Who can listen
|
|
68
|
+
<select name="visibility">
|
|
69
|
+
<option value="public">Public</option>
|
|
70
|
+
<option value="unlisted">Anyone with the link</option>
|
|
71
|
+
<option value="private">Invited people</option>
|
|
72
|
+
</select>
|
|
73
|
+
</label>
|
|
74
|
+
</div>
|
|
75
|
+
<div id="schedule-fields" class="form-pair" hidden>
|
|
76
|
+
<label>Date and time<input name="startsAt" type="datetime-local" /></label>
|
|
77
|
+
<label>Expected length
|
|
78
|
+
<select name="expectedDurationMinutes">
|
|
79
|
+
<option value="30">30 minutes</option>
|
|
80
|
+
<option value="60" selected>1 hour</option>
|
|
81
|
+
<option value="90">90 minutes</option>
|
|
82
|
+
<option value="120">2 hours</option>
|
|
83
|
+
</select>
|
|
84
|
+
</label>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="form-checks">
|
|
87
|
+
<label><input name="chatEnabled" type="checkbox" checked /> Chat</label>
|
|
88
|
+
<label><input name="handRaiseEnabled" type="checkbox" checked /> Raise hand</label>
|
|
89
|
+
<label><input name="recordingEnabled" type="checkbox" /> Record</label>
|
|
90
|
+
</div>
|
|
91
|
+
<p class="form-error" id="event-error" role="alert"></p>
|
|
92
|
+
<button class="button button-primary" type="submit" id="event-submit">Create live</button>
|
|
93
|
+
</form>
|
|
94
|
+
</dialog>
|
|
95
|
+
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "BackToSchool.help",
|
|
3
|
+
"short_name": "BackToSchool",
|
|
4
|
+
"description": "Learn something live.",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#f7f2e8",
|
|
8
|
+
"theme_color": "#34246d",
|
|
9
|
+
"icons": [
|
|
10
|
+
{
|
|
11
|
+
"src": "/icon.svg",
|
|
12
|
+
"sizes": "any",
|
|
13
|
+
"type": "image/svg+xml",
|
|
14
|
+
"purpose": "any maskable"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
package/dist/audio.d.ts
CHANGED
|
@@ -98,6 +98,19 @@ export interface Codecs {
|
|
|
98
98
|
* restart; a live channel is wherever it is now.
|
|
99
99
|
*/
|
|
100
100
|
duration?: number;
|
|
101
|
+
/**
|
|
102
|
+
* The size of the picture, when there is one.
|
|
103
|
+
*
|
|
104
|
+
* It decides the one thing that costs real money: whether a re-encode is
|
|
105
|
+
* asked to do 4K. Measured on this machine, 3840x2160 through libx264
|
|
106
|
+
* -preset veryfast runs at about half of real time, so a 4K film re-encoded
|
|
107
|
+
* at its own size arrives slower than it plays -- a stream that falls
|
|
108
|
+
* further behind every second. The same source scaled to 1080p runs at
|
|
109
|
+
* about 1.6x real time and keeps up. Copying, of course, costs nothing at
|
|
110
|
+
* any size, which is why what is inside matters more than how big it is.
|
|
111
|
+
*/
|
|
112
|
+
width?: number;
|
|
113
|
+
height?: number;
|
|
101
114
|
}
|
|
102
115
|
/**
|
|
103
116
|
* Ask ffprobe what the streams are, without holding the event loop.
|
|
@@ -107,6 +120,57 @@ export interface Codecs {
|
|
|
107
120
|
* the whole library came to be tagged with the process wedged solid.
|
|
108
121
|
*/
|
|
109
122
|
export declare function codecsOf(tools: Tools, path: string, input?: string[]): Promise<Codecs>;
|
|
123
|
+
/** How much of a transport stream is read before deciding what is in it. */
|
|
124
|
+
export declare const TRANSPORT_PROBE_BYTES: number;
|
|
125
|
+
export declare const TRANSPORT_ANALYSE_US = 10000000;
|
|
126
|
+
/**
|
|
127
|
+
* What to tell ffmpeg or ffprobe before it opens a transport stream.
|
|
128
|
+
*
|
|
129
|
+
* A `.ts` has no index and no header: it is packets, and the tracks are
|
|
130
|
+
* whatever turns up in them. The defaults are tuned for a file that describes
|
|
131
|
+
* itself, so a 4K recording -- padded with null packets, seconds between
|
|
132
|
+
* keyframes, sometimes several programmes -- gets read as having no picture,
|
|
133
|
+
* or no sound, or neither. Reading twenty megabytes before deciding costs a
|
|
134
|
+
* fraction of a second on a local disk and is the difference between a
|
|
135
|
+
* television recording and "nothing to play here".
|
|
136
|
+
*
|
|
137
|
+
* `+genpts` is for the other half of it: a recording that starts mid-stream
|
|
138
|
+
* has no timestamp on its first frames, and a fragmented MP4 built out of
|
|
139
|
+
* those has a duration of nothing and a seek bar that does not move.
|
|
140
|
+
* `+discardcorrupt` drops the half-packet at a cut rather than passing
|
|
141
|
+
* rubbish to the decoder.
|
|
142
|
+
*/
|
|
143
|
+
export declare function transportProbeArgs(path: string, container?: string): string[];
|
|
144
|
+
/** The same, for a decode rather than a probe: the timestamps matter too. */
|
|
145
|
+
export declare function transportInputArgs(path: string, container?: string): string[];
|
|
146
|
+
/** How the two sides of `-c:v copy` are told apart in a name a person reads. */
|
|
147
|
+
export interface VideoOptions {
|
|
148
|
+
/**
|
|
149
|
+
* Whether the thing at the other end can decode H.265.
|
|
150
|
+
*
|
|
151
|
+
* Safari and most televisions can; Chrome on a desktop cannot, and hands
|
|
152
|
+
* back nothing at all rather than an error anybody sees. So HEVC is only
|
|
153
|
+
* ever copied when the client said it could take it -- which is worth
|
|
154
|
+
* asking, because the alternative for a 4K HEVC film is an encode that does
|
|
155
|
+
* not keep up with playback.
|
|
156
|
+
*/
|
|
157
|
+
allowHevc?: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* The tallest picture a re-encode may produce. A copy is never resized: a
|
|
160
|
+
* 4K stream a browser can already decode is handed over as it is.
|
|
161
|
+
*/
|
|
162
|
+
maxHeight?: number;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The tallest re-encode that keeps up with playback.
|
|
166
|
+
*
|
|
167
|
+
* Measured on this box (8 cores, libx264 -preset veryfast, a 4K HEVC source):
|
|
168
|
+
* 4K out ran at 0.52x real time, 1080p out at 1.65x. An encode slower than
|
|
169
|
+
* real time is a live channel that falls behind for ever and a film that
|
|
170
|
+
* stalls every few seconds, so a re-encode of anything taller comes down to
|
|
171
|
+
* this. Copying is exempt, and copying is the ordinary case.
|
|
172
|
+
*/
|
|
173
|
+
export declare const MAX_TRANSCODE_HEIGHT = 1080;
|
|
110
174
|
/**
|
|
111
175
|
* How to get this file into a browser, given what is inside it.
|
|
112
176
|
*
|
|
@@ -115,8 +179,13 @@ export declare function codecsOf(tools: Tools, path: string, input?: string[]):
|
|
|
115
179
|
* is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
|
|
116
180
|
* would cost a core per viewer and look worse. So the streams decide, one part
|
|
117
181
|
* at a time -- a film can have its video copied and only its DTS re-encoded.
|
|
182
|
+
*
|
|
183
|
+
* Resolution is deliberately not one of the deciders for a copy. 1080p and 4K
|
|
184
|
+
* H.264 out of a transport stream are copied exactly as 720p is, because the
|
|
185
|
+
* work of copying does not grow with the picture and a browser that can decode
|
|
186
|
+
* 4K should be given 4K.
|
|
118
187
|
*/
|
|
119
|
-
export declare function videoArgs(codecs: Codecs, capKbps?: number): string[];
|
|
188
|
+
export declare function videoArgs(codecs: Codecs, capKbps?: number, options?: VideoOptions): string[];
|
|
120
189
|
/**
|
|
121
190
|
* The width that suits a bitrate.
|
|
122
191
|
*
|