@acms-subir/core 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/docs/live-video.md +91 -0
- package/integration.mjs +7 -0
- package/package.json +1 -1
- package/src/actions/index.ts +3 -2
- package/src/components/LinksManager.tsx +5 -1
- package/src/components/LiveCard.astro +174 -0
- package/src/components/LiveManager.tsx +910 -0
- package/src/components/ThemeSections.astro +2 -1
- package/src/components/WidgetRenderer.astro +3 -0
- package/src/layouts/Admin.astro +1 -1
- package/src/layouts/Site.astro +28 -0
- package/src/lib/api.ts +6 -2
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/live/client.ts +88 -0
- package/src/lib/live/crypto.ts +28 -0
- package/src/lib/live/migration.ts +15 -0
- package/src/lib/live/providers.ts +152 -0
- package/src/lib/live/service.ts +170 -0
- package/src/lib/live/signals.ts +89 -0
- package/src/lib/live/store.ts +49 -0
- package/src/lib/live/types.ts +52 -0
- package/src/lib/live/view.ts +78 -0
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +58 -12
- package/src/lib/repo/types.ts +3 -0
- package/src/lib/scheduler.ts +2 -0
- package/src/lib/schema.ts +7 -1
- package/src/lib/themeTokens.ts +9 -0
- package/src/lib/webhook-sql.ts +68 -0
- package/src/lib/webhooks.ts +15 -7
- package/src/lib/widgets.ts +1 -0
- package/src/middleware.ts +10 -1
- package/src/pages/[...slug].astro +6 -0
- package/src/pages/admin/calendar/integrations.astro +2 -219
- package/src/pages/admin/integrations.astro +237 -0
- package/src/pages/admin/live.astro +12 -0
- package/src/pages/api/calendar/docs.ts +4 -2
- package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
- package/src/pages/api/live/settings.ts +45 -0
- package/src/pages/api/live/status.ts +7 -0
- package/src/pages/api/live/thumb.ts +48 -0
- package/src/pages/api/webhooks/docs.ts +61 -0
- package/src/pages/index.astro +6 -0
- package/src/pages/links.astro +6 -0
- package/src/styles/live-admin.css +126 -0
- package/src/styles/live.css +199 -0
- package/src/theme-sdk/contract.mjs +1 -0
- package/src/theme-sdk/tokens.mjs +3 -0
- package/src/themes/editorial/theme.css +5 -0
- package/src/themes/editorial/theme.json +2 -0
- package/src/themes/field/theme.json +1 -0
- package/src/themes/night/theme.json +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2555746: Add owner-configured Twitch and YouTube live-video connections with guided setup, encrypted credentials, Google read-only OAuth, connection tests and scheduled discovery. Shared homepage sections, widgets and links-page cards show fresh live status with optional click-to-load players, manual overrides and neutral fallback links. Include token renewal, quota backoff, stale-status expiry, safe backup/restore behavior and setup documentation. Live cards come in poster, split and compact-row styles with same-site thumbnails, optional viewer counts, an upcoming countdown and a slim offline row; optional site-wide signals (live bar, header marker, floating card) and a themeable `--live-*` token set.
|
|
8
|
+
- e93111f: Add signed outgoing webhooks for post/page publication, updates, unpublishing and deletion; design publication; media creation, alt-text updates and deletion; and content backup restoration. Content notifications cover admin, agents, bulk operations and scheduled posts, while pending drafts remain private. Persist new deliveries atomically with their database writes and lease deliveries to prevent simultaneous request/cron sends.
|
|
9
|
+
|
|
10
|
+
Move API & webhooks to the site-wide Team section, available without the calendar, and add `/api/webhooks/docs` with event contracts and receiver guidance. Migration 33 installs the transactional notification triggers. Existing explicit subscriptions remain unchanged; wildcard subscriptions include the new events.
|
|
11
|
+
|
|
3
12
|
## 0.3.0
|
|
4
13
|
|
|
5
14
|
### Minor Changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Live video connections
|
|
2
|
+
|
|
3
|
+
ACMS can discover a site's current YouTube or Twitch broadcast and display it on its homepage and links page. Each site owner registers their own developer application and supplies its credentials. ACMS makes API requests from the deployed site's server; there is no shared ACMS developer app, hosted proxy or shared quota. The streaming platform continues to broadcast and host the video.
|
|
4
|
+
|
|
5
|
+
Open **Integrations → Live video** (`/admin/live`) as an owner. Connect a provider, run a successful test, turn the connection on, and save the display mode as **Automatic**. A healthy connection can be offline: starting a broadcast happens on the streaming platform, not in ACMS.
|
|
6
|
+
|
|
7
|
+
## Before connecting
|
|
8
|
+
|
|
9
|
+
- Set the site's canonical URL in Settings. Use that exact origin to sign in and authorize YouTube, including whether it uses `www`. Production callbacks require HTTPS; localhost HTTP is allowed for development.
|
|
10
|
+
- Supply a strong `SESSION_SECRET` (at least 32 random characters), or a separate `LIVE_ENCRYPTION_KEY` of that length, using the host's secret configuration. Live credentials use AES-256-GCM and a separate encryption context. An explicit live key takes precedence. Losing/changing the effective encryption key requires re-entering credentials and reauthorizing.
|
|
11
|
+
- Ensure the independent scheduler runs every minute. Workers deployments use the existing scheduled handler/cron trigger; self-hosted Docker uses the existing supervised scheduler. Custom Node deployments must invoke the existing authenticated `/api/email/run` runner, which now also runs live checks. It is a shared operational runner despite its historical name. See [production deployment](../../../docs/production-deployment.md). Visitor traffic does not drive live-provider polling.
|
|
12
|
+
- The UI reports the live scheduler heartbeat, each connection's last successful check, next due check and any provider error. Public visitors only receive a sanitized snapshot.
|
|
13
|
+
|
|
14
|
+
## Twitch
|
|
15
|
+
|
|
16
|
+
1. Open the [Twitch application console](https://dev.twitch.tv/console/apps) and register a **Confidential** application. Follow Twitch's account/2FA requirements.
|
|
17
|
+
2. If registration requests an OAuth redirect URI, use the copyable address shown by ACMS (`https://your-site.example/admin/live`). This app-only lookup does not use a Twitch OAuth callback or broadcaster sign-in.
|
|
18
|
+
3. Copy the Client ID and generate/copy the Client Secret. Enter them with the channel username or HTTPS channel URL in ACMS.
|
|
19
|
+
4. Save, test, and turn the connection on. The test resolves the channel's stable ID.
|
|
20
|
+
|
|
21
|
+
ACMS obtains an app token with the client-credentials flow and checks Get Streams for that broadcaster. App tokens are [validated](https://dev.twitch.tv/docs/authentication/validate-tokens/) when acquired and hourly during use. Expired or revoked app tokens are reacquired; these tokens do not use refresh tokens. ACMS requests no stream key or broadcast-management permissions. See [app registration](https://dev.twitch.tv/docs/authentication/register-app/), [token flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#client-credentials-grant-flow), and [Get Streams](https://dev.twitch.tv/docs/api/reference/#get-streams).
|
|
22
|
+
|
|
23
|
+
## YouTube
|
|
24
|
+
|
|
25
|
+
1. Create/select a Google Cloud project and enable **YouTube Data API v3**.
|
|
26
|
+
2. Configure OAuth consent for the appropriate audience. During external-app testing, add the Google account that owns the intended channel as a test user.
|
|
27
|
+
3. Create an OAuth **Web application** client. Copy the exact callback shown in ACMS into Google's authorized redirect URIs: `https://your-site.example/api/live/oauth/youtube/callback`.
|
|
28
|
+
4. Save the Client ID and Client Secret in ACMS, then choose **Authorize YouTube**. Approve YouTube read-only access using the correct Google/Brand channel identity.
|
|
29
|
+
5. Confirm the available channel, test, and turn the connection on. Selecting another channel requires a new test. A typed channel ID cannot override the authorized identity.
|
|
30
|
+
|
|
31
|
+
The authorization-code flow uses session-bound, one-use state and PKCE. Access/refresh tokens stay encrypted on the server. ACMS lists active broadcasts, filters to the selected channel and public `live` broadcasts, and checks public video visibility/embedding permission. Private, unlisted and test broadcasts are excluded. Multiple current broadcasts use the latest actual start time with a stable ID tie-break. See [liveBroadcasts.list](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list) and [server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server).
|
|
32
|
+
|
|
33
|
+
External OAuth apps left in **Testing** receive refresh tokens for YouTube scopes that expire after seven days. The UI's Testing/Production choice is owner-reported; ACMS cannot verify the app's publishing status. Follow Google's applicable publishing and verification requirements before relying on a long-running connection. A revoked or expired grant requires reauthorization. See [Google token expiration](https://developers.google.com/identity/protocols/oauth2#expiration).
|
|
34
|
+
|
|
35
|
+
### Quota and timing
|
|
36
|
+
|
|
37
|
+
The normal target is a check each minute and visible changes within roughly one to two minutes, subject to provider latency, scheduler operation and quota. One single-page active-broadcast list per minute is about 1,440 units/day before extra calls. Live-video visibility checks, connection tests, pagination and optional upcoming lookups add usage. Check the owner's actual project limits and other apps sharing that project.
|
|
38
|
+
|
|
39
|
+
Current Google documentation assigns `liveBroadcasts.list` a cost of one unit, with a default shared 10,000-unit daily allocation for non-search/non-upload endpoints. Public `search.list` has a separate default 100-call daily allowance. ACMS therefore implements owner-authorized OAuth, not minute-by-minute public search. API-key-only discovery is not included in this release. See [Google's current quota table](https://developers.google.com/youtube/v3/determine_quota_cost).
|
|
40
|
+
|
|
41
|
+
Rate-limit/quota errors back off. The UI reports estimated workload and provider errors, not an invented remaining quota. Optional YouTube upcoming lookup is cached for up to 15 minutes while active detection continues normally. A scheduled start time never becomes a live badge on its own.
|
|
42
|
+
|
|
43
|
+
## Place the live card
|
|
44
|
+
|
|
45
|
+
- **Design → Pages:** add a **Live video** section; choose `card` or `player`, preview and publish using the existing design flow.
|
|
46
|
+
- **Links:** add a **Live video** block and save. This is a compact card referencing the same site-wide status.
|
|
47
|
+
- **Older widget pages:** add a **Live video** widget and choose its display mode. Rich-text-only/custom coded homepages must opt into a Design section or explicitly render the component.
|
|
48
|
+
- **Custom themes:** import `LiveCard.astro` from `@acms-subir/core/components/LiveCard.astro`, or use the built-in `live` section. ACMS never injects a new placement automatically into a custom template. If a custom route renders the component, set `Astro.response.headers.set('Cache-Control', 'no-store')` in that route's frontmatter; the built-in page routes handle this for you.
|
|
49
|
+
|
|
50
|
+
### How it looks
|
|
51
|
+
|
|
52
|
+
**What your site shows → How it looks** (shown unless live video is Off) controls presentation:
|
|
53
|
+
|
|
54
|
+
- **Card style** for sections set to `card`: **Poster** (thumbnail with a live tag, title below; default), **Split** (thumbnail beside the title; stacks in columns narrower than 700px) or **Compact row**. Sections set to `player` always use the split layout, with the poster as the play button. The Links block is its own compact layout and moves to the top of the Links page while you're live.
|
|
55
|
+
- **Thumbnail** (on by default): served from `/api/live/thumb`, which fetches the current broadcast's still from YouTube (`i.ytimg.com`) or Twitch (`static-cdn.jtvnw.net`) on the server and caches it for a minute. Visitors never contact the platform until they press play. The upstream URL is derived only from the current snapshot's validated id; the query string is a cache version.
|
|
56
|
+
- **Viewer count** (off by default): Twitch reports it in the existing streams call; YouTube adds `liveStreamingDetails` to the existing `videos.list` check at the same one-unit cost. It is only sent to visitors when this is on.
|
|
57
|
+
- **Site-wide signals** (all off by default), shown on every page only while a broadcast is live: a **live bar** that temporarily replaces the announcement bar, a **"Live" marker** in the header, and a **floating card** in the corner (hidden on pages that already show a live card). The bar and card can be dismissed for the rest of that broadcast in the visitor's session. They are filled in by the browser from `/api/live/status`, so pages keep their normal caching.
|
|
58
|
+
|
|
59
|
+
Offline, a card shows a single "Not live right now · Watch our channel" row when an offline link is set, and hides otherwise. Upcoming broadcasts show their start time on the poster ("Sat 7:00 PM · in 2 h").
|
|
60
|
+
|
|
61
|
+
### Theming
|
|
62
|
+
|
|
63
|
+
The markup is a stable class API: `.pc-live` with `data-layout` (`poster`, `split`, `row`, `links`) and `data-status` (`live`, `upcoming`, `offline`); parts `.pc-live-poster`, `.pc-live-tag`, `.pc-live-viewers`, `.pc-live-kicker`, `.pc-live-title-text`, `.pc-live-meta`, `.pc-live-btn`, `.pc-live-off`; site-wide `.pc-live-bar`, `.site-live`, `.pc-live-dock`. While a broadcast is live or upcoming, `<html data-live="live|upcoming">` is set, so a theme can style anything (for example `html[data-live='live'] .brand`).
|
|
64
|
+
|
|
65
|
+
Override these on `:root` in `theme.css`: `--live-color` (defaults to the theme token `live`, a tuned red), `--live-ink`, `--live-radius`, `--live-tag-radius` (keep it a rectangle), `--live-poster-ratio` and `--live-pulse`. The marker blink is disabled under `prefers-reduced-motion`.
|
|
66
|
+
|
|
67
|
+
All placements share the same status. Changing credentials or live mode takes effect independently of staged layout changes. Adding/reordering a placement still follows its existing save/publish workflow. Existing sites start with live display off.
|
|
68
|
+
|
|
69
|
+
Player mode loads the official platform iframe only after a visitor clicks. Autoplay is off. The external watch link stays available when playback is blocked. Twitch requires HTTPS, the embedding hostname in `parent`, and at least 400×300 pixels; narrow slots use a watch link. YouTube embeds preserve an origin referrer. The compact links card never embeds a player. Cross-domain embedding of an entire ACMS site may require additional Twitch parent domains and is not configured automatically. See [Twitch embeds](https://dev.twitch.tv/docs/embed/video-and-clips/) and [YouTube players](https://developers.google.com/youtube/iframe_api_reference).
|
|
70
|
+
|
|
71
|
+
## Manual, off, fallback and outages
|
|
72
|
+
|
|
73
|
+
**Manual** accepts an HTTPS watch URL and a bounded expiry of up to 12 hours (UI default: four hours). It overrides automatic detection until it expires; automatic detection then resumes. **End manual link now** resumes automatic immediately. **Off** hides every placement; turn off individual provider connections too if you want to stop their background checks.
|
|
74
|
+
|
|
75
|
+
An optional neutral “Watch our channel” URL appears when no fresh live/upcoming broadcast is available. Provider priority chooses the first currently live enabled source; a live source always takes precedence over an upcoming one. No replay is automatically labelled live, and replay libraries are not part of this release.
|
|
76
|
+
|
|
77
|
+
A successful live observation expires after two minutes. Failures never extend that expiry. A confirmed offline result clears the badge, revoked access clears live state and disables that connection, and expired state hides the badge. Visible browser tabs refresh the sanitized status every 30 seconds and on focus; an expiry timer removes stale badges even if requests fail. Without JavaScript, status refreshes on the next page load. Pages containing live cards and the public status endpoint are not cached.
|
|
78
|
+
|
|
79
|
+
## Storage, permissions and recovery
|
|
80
|
+
|
|
81
|
+
Migration 34 adds a `settings.live` display configuration, a separate `live_connections` vault, one-use `live_oauth_states`, and a live scheduler heartbeat. Credentials never enter generic Settings, theme props, public API responses, React hydration or ordinary content exports. Owner-only APIs use existing authenticated sessions plus same-origin writes. Public `/api/live/status` only returns the selected public snapshot and server time.
|
|
82
|
+
|
|
83
|
+
Each background check claims a durable lease. Settings revisions and lease tokens prevent a delayed check from overwriting a newer edit/disconnection; concurrent runners cannot refresh the same connection simultaneously. Requests have timeouts, response-size limits and redacted errors. Provider requests use fixed official endpoints; pasted URLs are never fetched by the server.
|
|
84
|
+
|
|
85
|
+
Saving a connection disables it until tested again. Blank secrets preserve the saved secret only for the same client ID; changing the client ID or supplying a new secret clears incompatible authorization. Disconnect removes local credentials and state immediately and attempts provider revocation. Other applications using the same owner developer project may share quota or authorization consequences.
|
|
86
|
+
|
|
87
|
+
Content backups include inert display preferences but no connection secrets or tokens. Restore forces live mode off and disables any destination connections until retested; it neither imports credentials nor resumes a copied live badge. On a new host, enter app credentials, update Google's callback, authorize and test again.
|
|
88
|
+
|
|
89
|
+
Facebook HTTPS watch links work in manual mode. A Facebook automatic Page connector, Twitch EventSub and the slower YouTube API-key mode remain separate follow-up releases. They are not represented as working connectors in the UI.
|
|
90
|
+
|
|
91
|
+
Validation uses simulated provider HTTP responses for token renewal, filtering, expiry, quotas and OAuth attacks, plus SQLite/D1 concurrency and real local HTTP rendering tests. A real owner-owned Twitch/Google app is still needed to validate its external consent and account permissions end to end.
|
package/integration.mjs
CHANGED
|
@@ -54,6 +54,13 @@ const ROUTES = [
|
|
|
54
54
|
['/admin/posts', 'admin/posts/index.astro'],
|
|
55
55
|
['/admin/posts/[id]', 'admin/posts/[id].astro'],
|
|
56
56
|
['/admin/settings', 'admin/settings.astro'],
|
|
57
|
+
['/admin/integrations', 'admin/integrations.astro'],
|
|
58
|
+
['/admin/live', 'admin/live.astro'],
|
|
59
|
+
['/api/live/settings', 'api/live/settings.ts'],
|
|
60
|
+
['/api/live/status', 'api/live/status.ts'],
|
|
61
|
+
['/api/live/thumb', 'api/live/thumb.ts'],
|
|
62
|
+
['/api/live/oauth/youtube/callback', 'api/live/oauth/youtube/callback.ts'],
|
|
63
|
+
['/api/webhooks/docs', 'api/webhooks/docs.ts'],
|
|
57
64
|
['/admin/email', 'admin/email.astro'],
|
|
58
65
|
['/account/[action]', 'account/[action].astro'],
|
|
59
66
|
['/api/email/settings', 'api/email/settings.ts'],
|
package/package.json
CHANGED
package/src/actions/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { ActionAPIContext } from 'astro:actions';
|
|
|
7
7
|
import { getRepo } from '../lib/repo';
|
|
8
8
|
import { getMediaStore } from '../lib/media/store';
|
|
9
9
|
import { validateSession, SESSION_COOKIE } from '../lib/auth';
|
|
10
|
-
import { parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
|
|
10
|
+
import { LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
|
|
11
11
|
import { env } from '../lib/env';
|
|
12
12
|
import { CONTENT, collectionUrl } from '../lib/content';
|
|
13
13
|
import { invalidateRedirectCache } from '../middleware';
|
|
@@ -393,7 +393,8 @@ export const server = {
|
|
|
393
393
|
links: {
|
|
394
394
|
create: defineAction({
|
|
395
395
|
input: z.object({
|
|
396
|
-
kind
|
|
396
|
+
// every block kind the schema knows, incl. 'live' (kept in one place)
|
|
397
|
+
kind: z.enum(LinkKind.options).default('link'),
|
|
397
398
|
label: z.string().default(''),
|
|
398
399
|
url: z.string().default(''),
|
|
399
400
|
icon: z.string().default(''),
|
|
@@ -61,6 +61,7 @@ const SITES = [
|
|
|
61
61
|
];
|
|
62
62
|
|
|
63
63
|
const TILE_ICONS: Record<string, string> = {
|
|
64
|
+
live: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M3 5h12v14H3zM15 10l6-4v12l-6-4"/></svg>',
|
|
64
65
|
site: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 3l4 2.3v4.4L12 12 8 9.7V5.3L12 3zM6 13l4 2.3v4.4L6 22l-4-2.3v-4.4L6 13zM18 13l4 2.3v4.4L18 22l-4-2.3v-4.4L18 13z"/></svg>',
|
|
65
66
|
link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M10 14a5 5 0 007.07 0l2.83-2.83a5 5 0 00-7.07-7.07L11 5.93M14 10a5 5 0 00-7.07 0L4.1 12.83a5 5 0 007.07 7.07L13 18.07"/></svg>',
|
|
66
67
|
email: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>',
|
|
@@ -87,6 +88,7 @@ const TILE_GROUPS: { label: string; tiles: Tile[] }[] = [
|
|
|
87
88
|
{
|
|
88
89
|
label: 'Content & layout',
|
|
89
90
|
tiles: [
|
|
91
|
+
{ key: 'live', kind: 'live', name: 'Live video', desc: 'Your shared live broadcast' },
|
|
90
92
|
{ key: 'heading', kind: 'heading', name: 'Heading', desc: 'Group your links' },
|
|
91
93
|
{ key: 'text', kind: 'text', name: 'Text', desc: 'Non-clickable copy' },
|
|
92
94
|
{ key: 'spacer', kind: 'spacer', name: 'Spacer', desc: 'Blank vertical space' },
|
|
@@ -96,6 +98,7 @@ const TILE_GROUPS: { label: string; tiles: Tile[] }[] = [
|
|
|
96
98
|
];
|
|
97
99
|
|
|
98
100
|
const NEW_ITEM: Record<Kind, Partial<Link>> = {
|
|
101
|
+
live: { kind: 'live', label: 'Live video', url: '' },
|
|
99
102
|
link: { kind: 'link', label: 'New link', url: '', icon: '' },
|
|
100
103
|
email: { kind: 'email', label: 'E-mail me', url: '', icon: '✉️' },
|
|
101
104
|
phone: { kind: 'phone', label: 'Call me', url: '', icon: '📞' },
|
|
@@ -448,6 +451,7 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
448
451
|
// ---- rows -----------------------------------------------------------------
|
|
449
452
|
|
|
450
453
|
const KIND_BADGE: Partial<Record<Kind, string>> = {
|
|
454
|
+
live: '●',
|
|
451
455
|
heading: 'H', text: 'T', spacer: '↕', html: 'HTML', email: '@', phone: 'TEL', vcard: 'VCF',
|
|
452
456
|
};
|
|
453
457
|
|
|
@@ -493,7 +497,7 @@ function ItemRow({
|
|
|
493
497
|
<span className="lm-badge">{KIND_BADGE[k]}</span>
|
|
494
498
|
)}
|
|
495
499
|
|
|
496
|
-
{k === 'spacer' ? (
|
|
500
|
+
{k === 'live' ? <span style={{ flex: 1 }}>Live video · <a href="/admin/live">Manage broadcast</a></span> : k === 'spacer' ? (
|
|
497
501
|
<>
|
|
498
502
|
<span className="bare strong" style={{ padding: '5px 8px' }}>Spacer</span>
|
|
499
503
|
<select
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getRepo } from '../lib/repo';
|
|
3
|
+
import { liveState, publicSnapshot } from '../lib/live/service';
|
|
4
|
+
import { liveView } from '../lib/live/view';
|
|
5
|
+
import '../styles/live.css';
|
|
6
|
+
|
|
7
|
+
// One component for every live placement. Layout:
|
|
8
|
+
// compact (links page) → 'links'; display 'player' → 'split' with an
|
|
9
|
+
// in-page player; otherwise the owner's card style (poster/split/row).
|
|
10
|
+
// Theme hooks: .pc-live-* classes, --live-* tokens, [data-layout], [data-status].
|
|
11
|
+
interface Props { compact?: boolean; display?: string }
|
|
12
|
+
const { compact = false, display = 'card' } = Astro.props;
|
|
13
|
+
const { config, snapshot: raw } = await liveState(await getRepo(Astro.locals));
|
|
14
|
+
const snapshot = publicSnapshot(raw, config);
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
const v = liveView(snapshot, now);
|
|
17
|
+
const player = !compact && display === 'player';
|
|
18
|
+
const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
19
|
+
---
|
|
20
|
+
<section
|
|
21
|
+
class="pc-live"
|
|
22
|
+
data-live-card
|
|
23
|
+
data-layout={layout}
|
|
24
|
+
data-player={player ? '' : undefined}
|
|
25
|
+
data-pin={compact ? '' : undefined}
|
|
26
|
+
data-status={v?.status ?? 'offline'}
|
|
27
|
+
data-snapshot={JSON.stringify(snapshot)}
|
|
28
|
+
data-server-now={now}
|
|
29
|
+
hidden={!v}
|
|
30
|
+
aria-label="Live video"
|
|
31
|
+
>
|
|
32
|
+
<div class="pc-live-off">
|
|
33
|
+
<i class="pc-live-off-dot" aria-hidden="true"></i>
|
|
34
|
+
<span>Not live right now</span>
|
|
35
|
+
<a data-live-watch href={v?.watchUrl || '#'} target="_blank" rel="noopener noreferrer">{v?.watchLabel}</a>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="pc-live-body">
|
|
38
|
+
<div class="pc-live-media">
|
|
39
|
+
<a class="pc-live-poster" data-live-poster href={v?.watchUrl || '#'} target="_blank" rel="noopener noreferrer" aria-label={v ? `${v.watchLabel.replace(' ↗', '')}: ${v.title}` : undefined}>
|
|
40
|
+
<span class="pc-live-ph" aria-hidden="true">
|
|
41
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2" /><path d="M16.2 7.8a6 6 0 0 1 0 8.4M7.8 16.2a6 6 0 0 1 0-8.4M19 5a10 10 0 0 1 0 14M5 19A10 10 0 0 1 5 5" /></svg>
|
|
42
|
+
</span>
|
|
43
|
+
<img data-live-thumb src={v?.thumbUrl || undefined} alt="" loading="lazy" decoding="async" hidden={!v?.thumbUrl} />
|
|
44
|
+
<span class="pc-live-tag" data-live-tag>{v?.tag}</span>
|
|
45
|
+
<span class="pc-live-viewers" data-live-viewers hidden={!v?.viewers}>{v?.viewers}</span>
|
|
46
|
+
<span class="pc-live-play" data-live-playicon aria-hidden="true" hidden></span>
|
|
47
|
+
</a>
|
|
48
|
+
<div class="pc-live-player" data-live-player hidden></div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="pc-live-info">
|
|
51
|
+
<span class="pc-live-kicker" data-live-kicker>{v?.kicker}</span>
|
|
52
|
+
<h2 class="pc-live-title"><span class="pc-live-title-text" data-live-title>{v?.title}</span></h2>
|
|
53
|
+
<p class="pc-live-meta" data-live-meta hidden={!v?.meta}>{v?.meta}</p>
|
|
54
|
+
<div class="pc-live-actions">
|
|
55
|
+
<button type="button" class="pc-live-btn" data-live-load hidden>Play here</button>
|
|
56
|
+
<a class="pc-live-btn" data-live-watch href={v?.watchUrl || '#'} target="_blank" rel="noopener noreferrer">{v?.watchLabel}</a>
|
|
57
|
+
</div>
|
|
58
|
+
<small class="pc-live-privacy" data-live-privacy hidden></small>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
<script>
|
|
63
|
+
import { subscribeLive, currentLive, serverNow } from '../lib/live/client';
|
|
64
|
+
import { liveView, PROVIDER_NAMES } from '../lib/live/view';
|
|
65
|
+
import type { PublicLiveSnapshot } from '../lib/live/types';
|
|
66
|
+
|
|
67
|
+
const q = <T extends HTMLElement>(root: Element, sel: string) => root.querySelector<T>(sel)!;
|
|
68
|
+
const qa = <T extends HTMLElement>(root: Element, sel: string) => Array.from(root.querySelectorAll<T>(sel));
|
|
69
|
+
|
|
70
|
+
function setupLiveCards() {
|
|
71
|
+
const cards = Array.from(document.querySelectorAll<HTMLElement>('[data-live-card]')).filter((c) => !c.dataset.initialized);
|
|
72
|
+
for (const card of cards) {
|
|
73
|
+
card.dataset.initialized = 'true';
|
|
74
|
+
let shownKey = '';
|
|
75
|
+
// the links page lifts its live block to the top while live; a marker
|
|
76
|
+
// remembers where the owner placed it
|
|
77
|
+
const home = card.hasAttribute('data-pin') ? document.createComment('live-block') : null;
|
|
78
|
+
if (home) card.before(home);
|
|
79
|
+
|
|
80
|
+
const media = q(card, '.pc-live-media');
|
|
81
|
+
const poster = q<HTMLAnchorElement>(card, '[data-live-poster]');
|
|
82
|
+
const player = q(card, '[data-live-player]');
|
|
83
|
+
const load = q(card, '[data-live-load]');
|
|
84
|
+
const img = q<HTMLImageElement>(card, '[data-live-thumb]');
|
|
85
|
+
img.addEventListener('load', () => { img.hidden = false; });
|
|
86
|
+
img.addEventListener('error', () => { img.hidden = true; });
|
|
87
|
+
|
|
88
|
+
const eligible = (s: PublicLiveSnapshot | null) =>
|
|
89
|
+
card.hasAttribute('data-player') && !!s?.embedId && s.status === 'live' &&
|
|
90
|
+
(s.provider === 'youtube' || (s.provider === 'twitch' && media.clientWidth >= 400));
|
|
91
|
+
|
|
92
|
+
function reset() {
|
|
93
|
+
player.replaceChildren();
|
|
94
|
+
player.hidden = true;
|
|
95
|
+
poster.hidden = false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function play() {
|
|
99
|
+
const s = currentLive();
|
|
100
|
+
if (!s || !eligible(s)) return false;
|
|
101
|
+
let src = '';
|
|
102
|
+
if (s.provider === 'youtube' && /^[\w-]{11}$/.test(s.embedId)) src = `https://www.youtube-nocookie.com/embed/${s.embedId}?autoplay=0`;
|
|
103
|
+
if (s.provider === 'twitch' && /^[a-z0-9_]{1,25}$/.test(s.embedId)) {
|
|
104
|
+
const u = new URL('https://player.twitch.tv/');
|
|
105
|
+
u.searchParams.set('channel', s.embedId);
|
|
106
|
+
u.searchParams.set('parent', location.hostname);
|
|
107
|
+
u.searchParams.set('autoplay', 'false');
|
|
108
|
+
src = u.href;
|
|
109
|
+
}
|
|
110
|
+
if (!src) return false;
|
|
111
|
+
const iframe = document.createElement('iframe');
|
|
112
|
+
iframe.src = src;
|
|
113
|
+
iframe.title = s.title || 'Live video';
|
|
114
|
+
iframe.allowFullscreen = true;
|
|
115
|
+
iframe.allow = 'fullscreen; picture-in-picture; encrypted-media';
|
|
116
|
+
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
|
117
|
+
player.replaceChildren(iframe);
|
|
118
|
+
player.hidden = false;
|
|
119
|
+
poster.hidden = true;
|
|
120
|
+
load.hidden = true;
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
poster.addEventListener('click', (e) => { if (play()) e.preventDefault(); });
|
|
124
|
+
load.addEventListener('click', () => { play(); });
|
|
125
|
+
|
|
126
|
+
function pin(live: boolean) {
|
|
127
|
+
if (!home) return;
|
|
128
|
+
const slot = document.querySelector('[data-live-slot]');
|
|
129
|
+
if (live && slot) slot.after(card);
|
|
130
|
+
else if (!live && home.isConnected) home.after(card);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function render(s: PublicLiveSnapshot | null, now: number) {
|
|
134
|
+
const v = liveView(s, now);
|
|
135
|
+
card.hidden = !v;
|
|
136
|
+
const key = s ? `${s.provider}:${s.id}:${s.status}:${s.embedId}` : '';
|
|
137
|
+
if (key !== shownKey || !eligible(s)) { if (!player.hidden) reset(); }
|
|
138
|
+
shownKey = key;
|
|
139
|
+
pin(v?.status === 'live');
|
|
140
|
+
if (!v) return;
|
|
141
|
+
card.dataset.status = v.status;
|
|
142
|
+
q(card, '[data-live-tag]').textContent = v.tag;
|
|
143
|
+
q(card, '[data-live-kicker]').textContent = v.kicker;
|
|
144
|
+
q(card, '[data-live-title]').textContent = v.title;
|
|
145
|
+
const meta = q(card, '[data-live-meta]');
|
|
146
|
+
meta.textContent = v.meta;
|
|
147
|
+
meta.hidden = !v.meta;
|
|
148
|
+
const viewers = q(card, '[data-live-viewers]');
|
|
149
|
+
viewers.textContent = v.viewers;
|
|
150
|
+
viewers.hidden = !v.viewers;
|
|
151
|
+
for (const a of [poster, ...qa<HTMLAnchorElement>(card, '[data-live-watch]')]) a.href = v.watchUrl;
|
|
152
|
+
for (const a of qa(card, '[data-live-watch]')) a.textContent = v.watchLabel;
|
|
153
|
+
if (!v.thumbUrl) { img.hidden = true; img.removeAttribute('src'); }
|
|
154
|
+
else if (img.getAttribute('src') !== v.thumbUrl) img.src = v.thumbUrl;
|
|
155
|
+
const canPlay = eligible(s);
|
|
156
|
+
const name = PROVIDER_NAMES[v.provider] || 'the platform';
|
|
157
|
+
poster.setAttribute('aria-label', canPlay ? `Play ${v.title} here` : `${v.watchLabel.replace(' ↗', '')}: ${v.title}`);
|
|
158
|
+
q(card, '[data-live-playicon]').hidden = !canPlay;
|
|
159
|
+
load.hidden = !canPlay || !player.hidden;
|
|
160
|
+
const privacy = q(card, '[data-live-privacy]');
|
|
161
|
+
privacy.hidden = !canPlay;
|
|
162
|
+
privacy.textContent = `Playing loads video from ${name}.`;
|
|
163
|
+
card.dataset.playable = canPlay ? 'true' : 'false';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let initial: PublicLiveSnapshot | null = null;
|
|
167
|
+
try { initial = JSON.parse(card.dataset.snapshot || 'null'); } catch { /* no initial state */ }
|
|
168
|
+
subscribeLive(card, render, { snapshot: initial, serverNow: Number(card.dataset.serverNow) || Date.now() });
|
|
169
|
+
new ResizeObserver(() => render(currentLive(), serverNow())).observe(media);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
setupLiveCards();
|
|
173
|
+
document.addEventListener('astro:page-load', setupLiveCards);
|
|
174
|
+
</script>
|