diff --git a/.gitignore b/.gitignore index a20d9541c..370280c0c 100644 --- a/.gitignore +++ b/.gitignore @@ -459,6 +459,7 @@ FodyWeavers.xsd bin/ obj/ +aspire-output/ tmp-ts-validation/ .playwright-mcp/ diff --git a/Aspire.Dev.slnx b/Aspire.Dev.slnx index 5aa406d83..b4460a33e 100644 --- a/Aspire.Dev.slnx +++ b/Aspire.Dev.slnx @@ -6,6 +6,7 @@ + diff --git a/src/apphost/Aspire.Dev.AppHost/AppHost.cs b/src/apphost/Aspire.Dev.AppHost/AppHost.cs index 10b61e4e2..692b60be3 100644 --- a/src/apphost/Aspire.Dev.AppHost/AppHost.cs +++ b/src/apphost/Aspire.Dev.AppHost/AppHost.cs @@ -3,18 +3,33 @@ // For deployment: We want to pick AppService as the environment to publish to. builder.AddAzureAppServiceEnvironment("production"); +var cache = builder.AddAzureManagedRedis("livecache") + .RunAsContainer(); + var staticHostWebsite = builder.AddProject("aspiredev") + .WithReference(cache) .WithExternalHttpEndpoints(); -builder.AddAzureFrontDoor(staticHostWebsite); - if (builder.ExecutionContext.IsRunMode) { - // For local development: Use ViteApp for hot reload and development experience + staticHostWebsite.WithLocalLiveStatusDevCommands(); + + // For local development: Use ViteApp for hot reload and development experience. + // The live-status client calls same-origin /api/live[/stream]; inject StaticHost's + // origin so the Vite dev server can proxy those to the API (see astro.config.mjs). + // Without this, /api/live 404s against the Vite origin under `aspire run`. builder.AddViteApp("frontend", "../../frontend") .WithPnpm() + .WithEnvironment("ASPIRE_STATICHOST_URL", staticHostWebsite.GetEndpoint("https")) .WithUrlForEndpoint("http", static url => url.DisplayText = "aspire.dev (Local)") .WithExternalHttpEndpoints(); } +else +{ + var siteSecrets = builder.AddAzureKeyVault("siteconfig"); + staticHostWebsite.WithProductionLiveStatus(builder, siteSecrets); +} + +builder.AddAzureFrontDoor(staticHostWebsite); builder.Build().Run(); diff --git a/src/apphost/Aspire.Dev.AppHost/Aspire.Dev.AppHost.csproj b/src/apphost/Aspire.Dev.AppHost/Aspire.Dev.AppHost.csproj index 02ca7acd5..b73401b69 100644 --- a/src/apphost/Aspire.Dev.AppHost/Aspire.Dev.AppHost.csproj +++ b/src/apphost/Aspire.Dev.AppHost/Aspire.Dev.AppHost.csproj @@ -12,6 +12,8 @@ + + @@ -19,4 +21,8 @@ + + + + diff --git a/src/apphost/Aspire.Dev.AppHost/LiveExtensions.cs b/src/apphost/Aspire.Dev.AppHost/LiveExtensions.cs new file mode 100644 index 000000000..6d876cbe8 --- /dev/null +++ b/src/apphost/Aspire.Dev.AppHost/LiveExtensions.cs @@ -0,0 +1,323 @@ +using System.Security.Cryptography; +using System.Text; + +using Aspire.Hosting.Azure; +using Azure.Provisioning.KeyVault; + +internal static class LiveExtensions +{ + public static IResourceBuilder WithProductionLiveStatus( + this IResourceBuilder staticHostWebsite, + IDistributedApplicationBuilder builder, + IResourceBuilder siteSecrets) + { + var publicBaseUrl = builder.AddParameter( + "live-public-base-url", + "https://aspire.dev", + publishValueAsDefault: true); + var coalesceWindow = builder.AddParameter( + "live-coalesce-window-ms", + "750", + publishValueAsDefault: true); + + var twitchClientId = builder.AddParameter("live-twitch-client-id"); + var twitchChannelLogin = builder.AddParameter( + "live-twitch-channel-login", + "aspiredotdev", + publishValueAsDefault: true); + var twitchChannelId = builder.AddParameter("live-twitch-channel-id"); + var twitchReconcileInterval = builder.AddParameter( + "live-twitch-reconcile-interval-seconds", + "1800", + publishValueAsDefault: true); + + var youtubeChannelHandle = builder.AddParameter( + "live-youtube-channel-handle", + "@aspiredotdev", + publishValueAsDefault: true); + var youtubeChannelId = builder.AddParameter("live-youtube-channel-id"); + var youtubePollingInterval = builder.AddParameter( + "live-youtube-polling-interval-seconds", + "120", + publishValueAsDefault: true); + var youtubeDiscoveryPollingInterval = builder.AddParameter( + "live-youtube-discovery-polling-interval-seconds", + "1800", + publishValueAsDefault: true); + var youtubeOfflineConfirmationCount = builder.AddParameter( + "live-youtube-offline-confirmation-count", + "2", + publishValueAsDefault: true); + + return staticHostWebsite + .WithRoleAssignments(siteSecrets, KeyVaultBuiltInRole.KeyVaultSecretsUser) + .WithReference(siteSecrets) + .WithEnvironment("Live__PublicBaseUrl", publicBaseUrl) + .WithEnvironment("Live__CoalesceWindowMs", coalesceWindow) + .WithEnvironment("Live__Twitch__ClientId", twitchClientId) + .WithEnvironment("Live__Twitch__ChannelLogin", twitchChannelLogin) + .WithEnvironment("Live__Twitch__ChannelId", twitchChannelId) + .WithEnvironment("Live__Twitch__ReconcileIntervalSeconds", twitchReconcileInterval) + .WithEnvironment("Live__YouTube__ChannelHandle", youtubeChannelHandle) + .WithEnvironment("Live__YouTube__ChannelId", youtubeChannelId) + .WithEnvironment("Live__YouTube__PollingIntervalSeconds", youtubePollingInterval) + .WithEnvironment("Live__YouTube__DiscoveryPollingIntervalSeconds", youtubeDiscoveryPollingInterval) + .WithEnvironment("Live__YouTube__OfflineConfirmationCount", youtubeOfflineConfirmationCount); + } + + public static IResourceBuilder WithLocalLiveStatusDevCommands(this IResourceBuilder staticHostWebsite) + { + var liveDevCommandSecret = LiveDevCommands.NewSecret(); + var liveDevTwitchWebhookSecret = LiveDevCommands.NewSecret(); + var liveDevYouTubeWebhookSecret = LiveDevCommands.NewSecret(); + + return staticHostWebsite + // Local AppHost runs are the explicit live-status dev mode: external providers stay idle + // when no API credentials are configured, but dashboard commands can still exercise the + // UI and webhook paths with per-run local-only signing secrets. + .WithEnvironment("Live__EnableDevEndpoint", "true") + .WithEnvironment("Live__DevCommandSecret", liveDevCommandSecret) + .WithEnvironment("Live__Twitch__WebhookSecret", liveDevTwitchWebhookSecret) + .WithEnvironment("Live__YouTube__WebhookSecret", liveDevYouTubeWebhookSecret) + .WithUrlForEndpoint("http", static url => url.DisplayText = "aspire.dev (StaticHost)") + .WithLiveStatusUrls() + .WithLiveStatusCommands( + liveDevCommandSecret, + liveDevTwitchWebhookSecret, + liveDevYouTubeWebhookSecret); + } + + private static IResourceBuilder WithLiveStatusUrls(this IResourceBuilder staticHostWebsite) => + staticHostWebsite.WithUrls(ctx => + { + if (ctx.Resource is not IResourceWithEndpoints withEndpoints) + { + return; + } + + var endpoint = withEndpoints.GetEndpoint("http"); + if (endpoint is null) + { + return; + } + + ctx.Urls.Add(new() { Url = "/api/live", DisplayText = "Live status (JSON)", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/api/live/stream", DisplayText = "Live status (SSE stream)", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/api/live/twitch/webhook", DisplayText = "Twitch EventSub webhook (POST)", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/api/live/youtube/webhook", DisplayText = "YouTube WebSub webhook (GET/POST)", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/api/live/_dev/set", DisplayText = "Live dev override", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/scalar/v1", DisplayText = "API reference (Scalar)", Endpoint = endpoint }); + }); + + private static IResourceBuilder WithLiveStatusCommands( + this IResourceBuilder 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 33b675142..aa9bcb1c2 100644 --- a/src/frontend/astro.config.mjs +++ b/src/frontend/astro.config.mjs @@ -28,10 +28,19 @@ import Icons from 'starlight-plugin-icons'; const modeArgIndex = process.argv.indexOf('--mode'); const isSkipSearchBuild = modeArgIndex >= 0 && process.argv[modeArgIndex + 1] === 'skip-search'; +const outDir = process.env.ASTRO_OUT_DIR; const isBuildTimingEnabled = process.env.BUILD_TIMING === '1'; const siteDescription = 'Aspire is a multi-language local dev-time orchestration tool chain for building, running, debugging, and deploying distributed applications.'; +// Under `aspire run` the frontend dev server (Vite) and StaticHost are separate +// origins. The live-status client fetches same-origin `/api/live` and streams +// `/api/live/stream`, so in dev those must be proxied to StaticHost. The AppHost +// injects its origin as ASPIRE_STATICHOST_URL; unset in CI/production builds +// (where StaticHost serves both the site and the API from one origin), so the +// proxy is simply omitted then. +const staticHostUrl = process.env.ASPIRE_STATICHOST_URL; + // Astro renders pages mostly on the main JS thread. Default `build.concurrency` // is 1, so a multi-vCPU CI runner is largely idle during the generate phase. // Internal benchmarks on a 12k-page build showed: @@ -47,6 +56,7 @@ const buildConcurrency = Number(process.env.ASPIRE_BUILD_CONCURRENCY) || 4; // https://astro.build/config export default defineConfig({ + ...(outDir ? { outDir } : {}), prefetch: true, site: 'https://aspire.dev', trailingSlash: 'always', @@ -236,4 +246,21 @@ export default defineConfig({ build: { concurrency: buildConcurrency, }, + ...(staticHostUrl + ? { + vite: { + server: { + proxy: { + // A regular-expression context bypasses Astro's trailing-slash + // routing for both the JSON snapshot and SSE stream. + '^/api/live(?:/.*)?$': { + target: staticHostUrl, + changeOrigin: true, + secure: false, + }, + }, + }, + }, + } + : {}), }); diff --git a/src/frontend/config/sidebar/community.topics.ts b/src/frontend/config/sidebar/community.topics.ts index b2df96a66..25c9d0438 100644 --- a/src/frontend/config/sidebar/community.topics.ts +++ b/src/frontend/config/sidebar/community.topics.ts @@ -150,23 +150,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 56fa69aed..3fa030a9b 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -26,6 +26,8 @@ "build": "pnpm git-env && pnpm compute-skill-digests && astro build", "build:skip-search": "pnpm git-env && pnpm compute-skill-digests && astro build --mode skip-search", "build:production": "pnpm git-env && pnpm compute-skill-digests && 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..6a4c64627 --- /dev/null +++ b/src/frontend/scripts/build-static-host.mjs @@ -0,0 +1,65 @@ +#!/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, + }, + ); +} finally { + // Restore in `finally`: astro build wipes the out dir before it (re)writes it, + // so if the build throws, scalar/.gitignore are already gone from wwwroot. These + // must run before tempDir is removed or the only preserved copies are lost. + restore(join(staticHostWwwroot, 'scalar'), 'scalar'); + const gitignorePath = join(staticHostWwwroot, '.gitignore'); + restore(gitignorePath, '.gitignore'); + if (!existsSync(gitignorePath)) { + writeFileSync(gitignorePath, gitignoreContents); + } + + 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/AsciinemaPlayer.astro b/src/frontend/src/components/AsciinemaPlayer.astro index 7f16215c4..74e08e743 100644 --- a/src/frontend/src/components/AsciinemaPlayer.astro +++ b/src/frontend/src/components/AsciinemaPlayer.astro @@ -95,11 +95,16 @@ const { diff --git a/src/frontend/src/components/DashboardCarousel.astro b/src/frontend/src/components/DashboardCarousel.astro index 9d7be700c..aec6fd735 100644 --- a/src/frontend/src/components/DashboardCarousel.astro +++ b/src/frontend/src/components/DashboardCarousel.astro @@ -828,11 +828,8 @@ const formatSlideLabel = (index: number, count: number, label: string) => }); } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } + document.addEventListener('astro:page-load', init); + if (document.readyState !== 'loading') init(); })(); diff --git a/src/frontend/src/components/IntegrationTotals.astro b/src/frontend/src/components/IntegrationTotals.astro index 9ab682a83..ddfde9dad 100644 --- a/src/frontend/src/components/IntegrationTotals.astro +++ b/src/frontend/src/components/IntegrationTotals.astro @@ -100,7 +100,9 @@ const totalDownloads = integrations.reduce((total, integration) => { border-radius: 0.75rem; padding: 1.25rem 1.5rem; text-align: center; - transition: border-color 0.2s ease, background 0.2s ease; + transition: + border-color 0.2s ease, + background 0.2s ease; margin-top: 0; } @@ -270,9 +272,5 @@ const totalDownloads = integrations.reduce((total, integration) => { // guarantees totals animate on first render regardless of script order; // subsequent calls after filter changes are idempotent (the animation is // a no-op when the current value already matches `data-target`). - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => animateCountUp(), { once: true }); - } else { - animateCountUp(); - } + document.addEventListener('astro:page-load', animateCountUp); diff --git a/src/frontend/src/components/LivePip.astro b/src/frontend/src/components/LivePip.astro new file mode 100644 index 000000000..d91252fe0 --- /dev/null +++ b/src/frontend/src/components/LivePip.astro @@ -0,0 +1,919 @@ +--- +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..b425fb8b0 --- /dev/null +++ b/src/frontend/src/components/LiveVideosTabs.astro @@ -0,0 +1,176 @@ +--- +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/LoopingImage.astro b/src/frontend/src/components/LoopingImage.astro index d73c30d72..dd8c8fabc 100644 --- a/src/frontend/src/components/LoopingImage.astro +++ b/src/frontend/src/components/LoopingImage.astro @@ -58,6 +58,7 @@ const height = src.height; diff --git a/src/frontend/src/components/TwitchEmbed.astro b/src/frontend/src/components/TwitchEmbed.astro index efcfbdb75..e4eb73a61 100644 --- a/src/frontend/src/components/TwitchEmbed.astro +++ b/src/frontend/src/components/TwitchEmbed.astro @@ -4,14 +4,16 @@ 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 */ parent?: string; /** Max width constraint — default 100% */ maxWidth?: string; + /** Autoplay the stream (muted, as browsers require) */ + autoplay?: boolean; } const { @@ -21,6 +23,7 @@ const { aspectRatio = '16 / 9', parent = Astro.url.hostname, maxWidth = '100%', + autoplay = false, } = Astro.props; if (!channel && !video) { @@ -29,8 +32,8 @@ if (!channel && !video) { const params = new URLSearchParams({ parent, - autoplay: 'false', - muted: 'false', + autoplay: autoplay ? 'true' : 'false', + muted: autoplay ? 'true' : 'false', }); let src: string; @@ -41,14 +44,18 @@ if (video) { } --- -
+
diff --git a/src/frontend/src/components/YouTubeCard.astro b/src/frontend/src/components/YouTubeCard.astro index 66aef35c6..96222f89a 100644 --- a/src/frontend/src/components/YouTubeCard.astro +++ b/src/frontend/src/components/YouTubeCard.astro @@ -45,34 +45,44 @@ const { href, title, description, tags } = Astro.props; diff --git a/src/frontend/src/components/YouTubeEmbed.astro b/src/frontend/src/components/YouTubeEmbed.astro index 1f13cefc8..381edffa4 100644 --- a/src/frontend/src/components/YouTubeEmbed.astro +++ b/src/frontend/src/components/YouTubeEmbed.astro @@ -1,9 +1,11 @@ --- export interface Props { /** YouTube video or live stream ID (the ?v= value) */ - videoId: string; - /** Accessible title for the iframe */ - title?: string; + videoId?: string; + /** YouTube channel ID for the channel live-stream embed */ + channelId?: string; + /** Accessible label for the iframe */ + title?: string | null; /** Aspect ratio — default 16/9 */ aspectRatio?: string; /** Autoplay the stream (muted, as browsers require) */ @@ -14,33 +16,45 @@ export interface Props { const { videoId, + channelId, title = 'YouTube video player', aspectRatio = '16 / 9', autoplay = false, maxWidth = '100%', } = Astro.props; +if (!videoId && !channelId) { + throw new Error('YouTubeEmbed requires either a `videoId` or `channelId` prop.'); +} + const params = new URLSearchParams({ rel: '0', modestbranding: '1', playsinline: '1', }); +if (channelId) { + params.set('channel', channelId); +} if (autoplay) { params.set('autoplay', '1'); params.set('mute', '1'); } -const src = `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`; +const embedPath = channelId ? 'live_stream' : encodeURIComponent(videoId!); +const src = `https://www.youtube-nocookie.com/embed/${embedPath}?${params.toString()}`; --- -
+
+ data-stream-provider="youtube">