staticHostWebsite,
+ string liveDevCommandSecret,
+ string liveDevTwitchWebhookSecret,
+ string liveDevYouTubeWebhookSecret) =>
+ staticHostWebsite
+ .WithHttpCommand(
+ path: "/api/live/_dev/set",
+ displayName: "Live: all offline",
+ endpointName: "http",
+ commandName: "live-dev-all-offline",
+ commandOptions: LiveDevCommands.SetStatus(
+ liveDevCommandSecret,
+ "Turns off both local live-status sources.",
+ """
+ {
+ "twitch": { "live": false, "channel": null, "title": null },
+ "youtube": { "live": false, "videoId": null }
+ }
+ """,
+ iconName: "LiveOff"))
+ .WithHttpCommand(
+ path: "/api/live/twitch/webhook",
+ displayName: "Simulate Twitch online webhook",
+ endpointName: "http",
+ commandName: "live-dev-twitch-online-webhook",
+ commandOptions: LiveDevCommands.TwitchWebhook(
+ liveDevCommandSecret,
+ liveDevTwitchWebhookSecret,
+ "Sends a signed stream.online notification to the local Twitch EventSub endpoint.",
+ "stream.online"))
+ .WithHttpCommand(
+ path: "/api/live/twitch/webhook",
+ displayName: "Simulate Twitch offline webhook",
+ endpointName: "http",
+ commandName: "live-dev-twitch-offline-webhook",
+ commandOptions: LiveDevCommands.TwitchWebhook(
+ liveDevCommandSecret,
+ liveDevTwitchWebhookSecret,
+ "Sends a signed stream.offline notification to the local Twitch EventSub endpoint.",
+ "stream.offline"))
+ .WithHttpCommand(
+ path: "/api/live/youtube/webhook",
+ displayName: "Simulate YouTube WebSub webhook",
+ endpointName: "http",
+ commandName: "live-dev-youtube-websub-webhook",
+ commandOptions: LiveDevCommands.YouTubeWebhook(
+ liveDevCommandSecret,
+ liveDevYouTubeWebhookSecret,
+ "Sends a signed Atom notification to the local YouTube WebSub endpoint. In dev mode without a YouTube API key, the payload directly sets YouTube live.",
+ videoId: "dev-live-video"))
+ .WithHttpCommand(
+ path: "/api/live/_dev/set",
+ displayName: "Live: YouTube offline",
+ endpointName: "http",
+ commandName: "live-dev-youtube-offline",
+ commandOptions: LiveDevCommands.SetStatus(
+ liveDevCommandSecret,
+ "Turns off only the local YouTube live-status source.",
+ """
+ {
+ "youtube": { "live": false, "videoId": null }
+ }
+ """,
+ iconName: "VideoOff"))
+ .WithHttpCommand(
+ path: "/api/live/_dev/set",
+ displayName: "Live: both online",
+ endpointName: "http",
+ commandName: "live-dev-both-online",
+ commandOptions: LiveDevCommands.SetStatus(
+ liveDevCommandSecret,
+ "Turns on both local live-status sources without going through provider webhook validation.",
+ """
+ {
+ "twitch": { "live": true, "channel": "aspiredotdev", "title": "Local dashboard test" },
+ "youtube": { "live": true, "videoId": "dev-live-video" }
+ }
+ """,
+ iconName: "Live"));
+}
+
+internal static class LiveDevCommands
+{
+ public const string CommandSecretHeaderName = "X-Aspire-Live-Dev-Command-Key";
+
+ public static string NewSecret() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
+
+ public static HttpCommandOptions SetStatus(
+ string commandSecret,
+ string description,
+ string body,
+ string iconName,
+ bool isHighlighted = false) =>
+ new()
+ {
+ Method = HttpMethod.Post,
+ Description = description,
+ IconName = iconName,
+ IconVariant = IconVariant.Regular,
+ IsHighlighted = isHighlighted,
+ PrepareRequest = context =>
+ {
+ AddCommandSecret(context, commandSecret);
+ context.Request.Content = Json(body, "application/json");
+ return Task.CompletedTask;
+ },
+ };
+
+ public static HttpCommandOptions TwitchWebhook(
+ string commandSecret,
+ string webhookSecret,
+ string description,
+ string subscriptionType) =>
+ new()
+ {
+ Method = HttpMethod.Post,
+ Description = description,
+ IconName = subscriptionType == "stream.online" ? "PlugConnected" : "PlugDisconnected",
+ IconVariant = IconVariant.Regular,
+ PrepareRequest = context =>
+ {
+ var body = $$"""
+ {
+ "subscription": { "type": "{{subscriptionType}}" },
+ "event": {
+ "broadcaster_user_id": "dev-aspire",
+ "broadcaster_user_login": "aspiredotdev",
+ "broadcaster_user_name": "Aspire",
+ "started_at": "{{DateTimeOffset.UtcNow:O}}"
+ }
+ }
+ """;
+
+ var messageId = Guid.NewGuid().ToString("N");
+ var timestamp = DateTimeOffset.UtcNow.ToString("O");
+ var bodyBytes = Encoding.UTF8.GetBytes(body);
+ var signature = SignTwitch(webhookSecret, messageId, timestamp, bodyBytes);
+
+ AddCommandSecret(context, commandSecret);
+ context.Request.Headers.Add("Twitch-Eventsub-Message-Id", messageId);
+ context.Request.Headers.Add("Twitch-Eventsub-Message-Timestamp", timestamp);
+ context.Request.Headers.Add("Twitch-Eventsub-Message-Type", "notification");
+ context.Request.Headers.Add("Twitch-Eventsub-Message-Signature", $"sha256={signature}");
+ context.Request.Content = Json(body, "application/json");
+ return Task.CompletedTask;
+ },
+ };
+
+ public static HttpCommandOptions YouTubeWebhook(
+ string commandSecret,
+ string webhookSecret,
+ string description,
+ string videoId) =>
+ new()
+ {
+ Method = HttpMethod.Post,
+ Description = description,
+ IconName = "ArrowSync",
+ IconVariant = IconVariant.Regular,
+ PrepareRequest = context =>
+ {
+ var body = $$"""
+
+
+
+ {{videoId}}
+ Local Aspire live-status test
+
+
+
+ """;
+
+ var bodyBytes = Encoding.UTF8.GetBytes(body);
+ var signature = SignYouTube(webhookSecret, bodyBytes);
+ AddCommandSecret(context, commandSecret);
+ context.Request.Headers.Add("X-Hub-Signature", $"sha1={signature}");
+ context.Request.Content = Json(body, "application/atom+xml");
+ return Task.CompletedTask;
+ },
+ };
+
+ private static void AddCommandSecret(HttpCommandRequestContext context, string commandSecret)
+ {
+ if (string.IsNullOrEmpty(commandSecret))
+ {
+ throw new InvalidOperationException("A live-status dashboard command secret is required.");
+ }
+
+ context.Request.Headers.Add(CommandSecretHeaderName, $"Key: {commandSecret}");
+ }
+
+ private static StringContent Json(string body, string mediaType) =>
+ new(body, Encoding.UTF8, mediaType);
+
+ private static string SignTwitch(string secret, string messageId, string timestamp, byte[] body)
+ {
+ using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
+ var messageBytes = Encoding.UTF8.GetBytes(messageId);
+ hmac.TransformBlock(messageBytes, 0, messageBytes.Length, null, 0);
+ var timestampBytes = Encoding.UTF8.GetBytes(timestamp);
+ hmac.TransformBlock(timestampBytes, 0, timestampBytes.Length, null, 0);
+ hmac.TransformFinalBlock(body, 0, body.Length);
+ return Convert.ToHexStringLower(hmac.Hash!);
+ }
+
+ private static string SignYouTube(string secret, byte[] body)
+ {
+ using var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(secret));
+ return Convert.ToHexStringLower(hmac.ComputeHash(body));
+ }
+}
diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs
index cb6aa8081..82734c919 100644
--- a/src/frontend/astro.config.mjs
+++ b/src/frontend/astro.config.mjs
@@ -23,9 +23,11 @@ import jopSoftwarecookieconsent from '@jop-software/astro-cookieconsent';
const modeArgIndex = process.argv.indexOf('--mode');
const isSkipSearchBuild = modeArgIndex >= 0 && process.argv[modeArgIndex + 1] === 'skip-search';
+const outDir = process.env.ASTRO_OUT_DIR;
// https://astro.build/config
export default defineConfig({
+ ...(outDir ? { outDir } : {}),
prefetch: true,
site: 'https://aspire.dev',
trailingSlash: 'always',
diff --git a/src/frontend/config/sidebar/community.topics.ts b/src/frontend/config/sidebar/community.topics.ts
index 98d57d11c..7666cb6fe 100644
--- a/src/frontend/config/sidebar/community.topics.ts
+++ b/src/frontend/config/sidebar/community.topics.ts
@@ -128,23 +128,23 @@ export const communityTopics: StarlightSidebarTopicsUserConfig = {
],
},
{
- label: 'Videos',
+ label: 'Live Streams',
translations: {
- da: 'Videoer',
- de: 'Videos',
- en: 'Videos',
- es: 'Videos',
- fr: 'Vidéos',
- hi: 'वीडियो',
- id: 'Video',
- it: 'Video',
- ja: '動画',
- ko: '비디오',
- 'pt-BR': 'Vídeos',
- ru: 'Видео',
- tr: 'Videolar',
- uk: 'Відео',
- 'zh-CN': '视频',
+ da: 'Livestreams',
+ de: 'Livestreams',
+ en: 'Live Streams',
+ es: 'Transmisiones en directo',
+ fr: 'Diffusions en direct',
+ hi: 'लाइव स्ट्रीम',
+ id: 'Siaran langsung',
+ it: 'Dirette streaming',
+ ja: 'ライブ配信',
+ ko: '라이브 스트림',
+ 'pt-BR': 'Transmissões ao vivo',
+ ru: 'Прямые эфиры',
+ tr: 'Canlı Yayınlar',
+ uk: 'Прямі трансляції',
+ 'zh-CN': '直播',
},
slug: 'community/videos',
},
diff --git a/src/frontend/package.json b/src/frontend/package.json
index 529a5b4cf..07c120d30 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -24,6 +24,8 @@
"build": "pnpm git-env && astro build",
"build:skip-search": "pnpm git-env && astro build --mode skip-search",
"build:production": "pnpm git-env && astro build --mode production",
+ "build:statichost": "node ./scripts/build-static-host.mjs",
+ "build:statichost:skip-search": "node ./scripts/build-static-host.mjs --skip-search",
"preview": "astro preview",
"preview:host": "astro preview --host",
"astro": "pnpm git-env && astro",
diff --git a/src/frontend/scripts/build-static-host.mjs b/src/frontend/scripts/build-static-host.mjs
new file mode 100644
index 000000000..ebd6f9460
--- /dev/null
+++ b/src/frontend/scripts/build-static-host.mjs
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+import { execSync } from 'node:child_process';
+import { cpSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+const frontendDir = resolve(scriptDir, '..');
+const staticHostWwwroot = resolve(frontendDir, '..', 'statichost', 'StaticHost', 'wwwroot');
+const tempDir = mkdtempSync(join(tmpdir(), 'aspire-statichost-wwwroot-'));
+const skipSearch = process.argv.includes('--skip-search');
+
+const gitignoreContents = `# Ignore local copies of src/frontend/dist used for StaticHost smoke testing.
+*
+!.gitignore
+!index.html
+!scalar/
+!scalar/**
+`;
+
+function preserve(path, name) {
+ if (!existsSync(path)) return;
+ cpSync(path, join(tempDir, name), { recursive: true });
+}
+
+function restore(path, name) {
+ const preserved = join(tempDir, name);
+ if (!existsSync(preserved)) return;
+ cpSync(preserved, path, { recursive: true });
+}
+
+function run(command, env = process.env) {
+ execSync(command, {
+ cwd: frontendDir,
+ env,
+ stdio: 'inherit',
+ });
+}
+
+try {
+ preserve(join(staticHostWwwroot, 'scalar'), 'scalar');
+ preserve(join(staticHostWwwroot, '.gitignore'), '.gitignore');
+
+ run('pnpm git-env');
+ run(
+ `pnpm exec astro build${skipSearch ? ' --mode skip-search' : ''}`,
+ {
+ ...process.env,
+ ASTRO_OUT_DIR: staticHostWwwroot,
+ },
+ );
+
+ restore(join(staticHostWwwroot, 'scalar'), 'scalar');
+ const gitignorePath = join(staticHostWwwroot, '.gitignore');
+ restore(gitignorePath, '.gitignore');
+ if (!existsSync(gitignorePath)) {
+ writeFileSync(gitignorePath, gitignoreContents);
+ }
+} finally {
+ rmSync(tempDir, { recursive: true, force: true });
+}
diff --git a/src/frontend/src/assets/icons/live.svg b/src/frontend/src/assets/icons/live.svg
new file mode 100644
index 000000000..8bed4e72e
--- /dev/null
+++ b/src/frontend/src/assets/icons/live.svg
@@ -0,0 +1 @@
+
diff --git a/src/frontend/src/components/LivePip.astro b/src/frontend/src/components/LivePip.astro
new file mode 100644
index 000000000..00df09e8b
--- /dev/null
+++ b/src/frontend/src/components/LivePip.astro
@@ -0,0 +1,817 @@
+---
+import { Icon } from '@astrojs/starlight/components';
+import LiveSvg from '@assets/icons/live.svg';
+
+/**
+ * Site-global native Document Picture-in-Picture controller.
+ *
+ * The header live icon remains a normal link when we're offline. When we're
+ * live, clicking the icon opens a compact action dialog so visitors can choose
+ * native PiP, the aspire.dev embeds, a provider site, or silence the strobe.
+ * Closing the native PiP window never redirects.
+ */
+---
+
+
+
+
+
+
diff --git a/src/frontend/src/components/LiveVideosTabs.astro b/src/frontend/src/components/LiveVideosTabs.astro
new file mode 100644
index 000000000..944645c4c
--- /dev/null
+++ b/src/frontend/src/components/LiveVideosTabs.astro
@@ -0,0 +1,118 @@
+---
+import { Tabs, TabItem } from '@astrojs/starlight/components';
+import YouTubeEmbed from '@components/YouTubeEmbed.astro';
+import TwitchEmbed from '@components/TwitchEmbed.astro';
+
+export interface Props {
+ youtubeChannelId: string;
+ twitchChannel: string;
+}
+
+const { youtubeChannelId, twitchChannel } = Astro.props;
+---
+
+
+
+
+
+
+
+ When we’re not live, this shows the channel placeholder. Browse all past streams on youtube.com/@aspiredotdev.
+
+
+
+
+
+ When we’re not live, the Twitch player shows the offline screen. Follow twitch.tv/{twitchChannel} to get notified.
+
+
+
+
+
+
+
diff --git a/src/frontend/src/components/TwitchEmbed.astro b/src/frontend/src/components/TwitchEmbed.astro
index efcfbdb75..d8c0d394e 100644
--- a/src/frontend/src/components/TwitchEmbed.astro
+++ b/src/frontend/src/components/TwitchEmbed.astro
@@ -4,8 +4,8 @@ export interface Props {
channel?: string;
/** Twitch video ID for a past broadcast (e.g. "1234567890") */
video?: string;
- /** Accessible title for the iframe */
- title?: string;
+ /** Accessible label for the iframe */
+ title?: string | null;
/** Aspect ratio — default 16/9 */
aspectRatio?: string;
/** Parent domain(s) required by Twitch embed — defaults to current site origin */
@@ -44,7 +44,7 @@ if (video) {
+
+
+