commit 4c16f4dc95ad78004e8bc6d0ec167e1219221559 parent 19a207d355c2b58fd165622556b65676118a6014 Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Thu, 2 Jul 2026 23:35:07 -0400 Merge feat/kick-vod-playback: Kick VOD playback + VOD-expiry indicators Diffstat:
19 files changed, 500 insertions(+), 7 deletions(-)
diff --git a/common/components/KickPlayer.tsx b/common/components/KickPlayer.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, +} from "react"; +import Hls from "hls.js"; + +export type KickPlayerHandle = { + seekTo: (seconds: number, unit?: "seconds") => void; +}; + +type Props = { + url: string; + playing?: boolean; + onReady?: () => void; + onPlay?: () => void; + onPause?: () => void; + onProgress?: (state: { playedSeconds: number }) => void; + onError?: () => void; +}; + +// Kick VODs have no iframe embed, so we play their persisted HLS manifest in a +// native <video> driven by the bundled hls.js. (react-player's file player loads +// hls.js from a CDN, which breaks the offline export PWA, so we drive it +// directly.) Exposes the same imperative `seekTo` handle as the other players so +// PlayerProvider can drive it, and emits ready/play/pause/progress/error the way +// the YouTube player does — giving Kick full-fidelity seeking + cue highlighting. +const KickPlayer = forwardRef<KickPlayerHandle, Props>(function KickPlayer( + { url, playing, onReady, onPlay, onPause, onProgress, onError }, + ref, +) { + const videoRef = useRef<HTMLVideoElement | null>(null); + + // Keep the latest callbacks in a ref so the hls setup effect can depend only + // on `url` — inline arrow props would otherwise tear down/rebuild hls.js on + // every render. + const cbRef = useRef({ onReady, onPlay, onPause, onProgress, onError }); + cbRef.current = { onReady, onPlay, onPause, onProgress, onError }; + + useImperativeHandle( + ref, + () => ({ + seekTo(seconds: number) { + const video = videoRef.current; + if (!video) return; + video.currentTime = Math.max(0, seconds); + void video.play().catch(() => {}); + }, + }), + [], + ); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const emitReady = () => cbRef.current.onReady?.(); + const emitPlay = () => cbRef.current.onPlay?.(); + const emitPause = () => cbRef.current.onPause?.(); + const emitProgress = () => + cbRef.current.onProgress?.({ playedSeconds: video.currentTime }); + + video.addEventListener("play", emitPlay); + video.addEventListener("pause", emitPause); + video.addEventListener("timeupdate", emitProgress); + + let hls: Hls | null = null; + // Safari (and iOS) plays HLS natively; everywhere else use hls.js + MSE. + if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = url; + video.addEventListener("loadedmetadata", emitReady, { once: true }); + } else if (Hls.isSupported()) { + hls = new Hls(); + hls.loadSource(url); + hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, emitReady); + hls.on(Hls.Events.ERROR, (_event, data) => { + // Only unrecoverable errors (e.g. an expired/deleted VOD whose manifest + // 404s) surface to the fallback; hls.js self-heals transient ones. + if (data.fatal) cbRef.current.onError?.(); + }); + } else { + cbRef.current.onError?.(); + } + + return () => { + video.removeEventListener("play", emitPlay); + video.removeEventListener("pause", emitPause); + video.removeEventListener("timeupdate", emitProgress); + if (hls) hls.destroy(); + }; + }, [url]); + + // Mirror the desired play/pause state. Autoplay may be rejected without a user + // gesture (e.g. opening via a shared ?v= link) — that's fine, the native + // controls let the user start it. + useEffect(() => { + const video = videoRef.current; + if (!video) return; + if (playing) void video.play().catch(() => {}); + else video.pause(); + }, [playing]); + + return ( + <video + ref={videoRef} + controls + playsInline + style={{ width: "100%", height: "100%", background: "black" }} + /> + ); +}); + +export default KickPlayer; diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx @@ -18,9 +18,12 @@ import { useUrlParams, writeUrlParams } from "./urlState"; import type { ModalMode } from "./urlState"; import { formatDate, formatDuration } from "../lib/format"; import type { Platform } from "../lib/transcripts"; +import { vodExpiry } from "../lib/vodExpiry"; +import { VodExpiredBadge } from "./badges"; import type { RumblePlayerHandle } from "./RumblePlayer"; import type { OdyseePlayerHandle } from "./OdyseePlayer"; import type { TwitchPlayerHandle } from "./TwitchPlayer"; +import type { KickPlayerHandle } from "./KickPlayer"; const ReactPlayer = dynamic(() => import("react-player/youtube"), { ssr: false, @@ -38,11 +41,19 @@ const TwitchPlayer = dynamic(() => import("./TwitchPlayer"), { ssr: false, }); +// Kick VODs have no iframe embed; they play from a persisted HLS manifest in a +// native <video> driven by the bundled hls.js. Exposes the same seekTo/onReady/ +// onProgress API as the YouTube player. +const KickPlayer = dynamic(() => import("./KickPlayer"), { + ssr: false, +}); + type PlayerHandle = | Pick<ReactPlayerType, "seekTo"> | RumblePlayerHandle | OdyseePlayerHandle - | TwitchPlayerHandle; + | TwitchPlayerHandle + | KickPlayerHandle; export type Cue = { start: number; end: number; text: string }; @@ -59,6 +70,7 @@ export type TranscriptData = { ageRestricted: boolean; platform: Platform; webpageUrl: string; + hlsUrl?: string; cues?: Cue[]; }; @@ -78,6 +90,7 @@ type Detail = { ageRestricted: boolean; platform: Platform; webpageUrl: string; + hlsUrl?: string; cues?: Cue[]; }; @@ -262,6 +275,9 @@ export function PlayerProvider({ const { detail, displayState, clip, chat } = core; const [playing, setPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); + // Set when the Kick HLS manifest fails to load (usually an expired VOD), so + // the player swaps to the expiry/link fallback. Reset per active video below. + const [kickError, setKickError] = useState(false); const playerRef = useRef<PlayerHandle | null>(null); const pendingSeekRef = useRef<number | null>(null); const readyForSlugRef = useRef<string | null>(null); @@ -295,6 +311,7 @@ export function PlayerProvider({ ageRestricted: detail.ageRestricted, platform: detail.platform, webpageUrl: detail.webpageUrl, + hlsUrl: detail.hlsUrl, cues: detail.cues, }; }, [detail, detailMatches]); @@ -374,7 +391,10 @@ export function PlayerProvider({ const copyShareUrl = useCallback(async (): Promise<boolean> => { if (!data) return false; - const t = data.platform === "youtube" ? currentTime : (urlTime ?? 0); + const t = + data.platform === "youtube" || data.platform === "kick" + ? currentTime + : (urlTime ?? 0); const secs = Math.max(0, Math.floor(t)); const params = new URLSearchParams(); params.set("v", data.slug); @@ -414,6 +434,7 @@ export function PlayerProvider({ useEffect(() => { dispatch({ type: "CHAT_RESET" }); chatInFlightForRef.current = null; + setKickError(false); if (!urlSlug) return; readyForSlugRef.current = null; pendingSeekRef.current = urlTime ?? 0; @@ -435,6 +456,7 @@ export function PlayerProvider({ ageRestricted: full.ageRestricted, platform: full.platform, webpageUrl: full.webpageUrl, + hlsUrl: full.hlsUrl, cues: full.cues, }, }); @@ -673,6 +695,33 @@ export function PlayerProvider({ videoId={data.id} startSeconds={urlTime ?? 0} /> + ) : data.platform === "kick" && data.hlsUrl && !kickError ? ( + // Kick VODs have no iframe embed — play the persisted HLS manifest + // in a native <video> via hls.js. Same seekTo/onReady/onProgress + // contract as the YouTube player. On a fatal load error (usually an + // expired VOD) fall through to the notice. + <KickPlayer + key={data.id} + ref={(p: KickPlayerHandle | null) => { + playerRef.current = p; + }} + url={data.hlsUrl} + playing={playing} + onReady={handleReady} + onPlay={() => setPlaying(true)} + onPause={() => setPlaying(false)} + onProgress={(s: { playedSeconds: number }) => + setCurrentTime(s.playedSeconds) + } + onError={() => setKickError(true)} + /> + ) : data.platform === "kick" ? ( + // Kick VOD with no playable manifest (expired or pre-feature + // archive): show the expiry notice + a link to the source. + <KickUnavailable + uploadDate={data.uploadDate} + webpageUrl={data.webpageUrl} + /> ) : data.platform === "youtube" ? ( <ReactPlayer ref={(p: ReactPlayerType | null) => { @@ -731,6 +780,37 @@ export function PlayerProvider({ ); } +// Fallback shown when a Kick VOD has no playable HLS manifest — an expired VOD +// (Kick keeps VODs ~30 days) or a pre-feature archive. Surfaces the expiry +// badge/notice when the age warrants it, plus a link to the source page. +function KickUnavailable({ + uploadDate, + webpageUrl, +}: { + uploadDate: string; + webpageUrl: string; +}) { + const expiry = vodExpiry("kick", uploadDate); + return ( + <div className="flex h-full w-full flex-col items-center justify-center gap-3 p-4 text-center"> + {expiry?.likelyExpired && <VodExpiredBadge tooltip={expiry.tooltip} />} + <p className="max-w-sm text-sm text-zinc-200"> + {expiry?.likelyExpired + ? "This Kick VOD has most likely been deleted (Kick keeps VODs about 30 days), so it can't be played here." + : "This Kick VOD couldn't be loaded for playback."} + </p> + <a + href={webpageUrl} + target="_blank" + rel="noopener noreferrer" + className="text-sm text-white underline hover:text-zinc-200" + > + Open on kick.com + </a> + </div> + ); +} + function HiddenControls({ title, onShow, diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -47,7 +47,12 @@ import { loadAdvanced, saveAdvanced, } from "./exportAdvancedStorage"; -import { AgeRestrictedBadge, LivestreamBadge } from "./badges"; +import { + AgeRestrictedBadge, + LivestreamBadge, + VodExpiredBadge, +} from "./badges"; +import { vodExpiry } from "../lib/vodExpiry"; import { Button } from "./ui/button"; import { Checkbox } from "./ui/checkbox"; import QueryBuilder from "./QueryBuilder"; @@ -61,7 +66,7 @@ import { ChartShapeControls } from "./charts/ChartShapeControls"; import { SearchChartPanel } from "./charts/SearchChartPanel"; import { LayerSwatch } from "./LayerSwatch"; import { ymdToInput, inputToYmd } from "../lib/ymd"; -import type { DisplaySummary } from "../lib/transcripts"; +import type { DisplaySummary, Platform } from "../lib/transcripts"; import { makeId, splitId } from "./originId"; import { sortGroups, type ChannelGroup } from "../lib/channelGroups"; @@ -74,6 +79,10 @@ type ResultGroup = { date: string; isLivestream: boolean; ageRestricted: boolean; + // Carried through so the card can flag likely-expired stream VODs (Kick, + // Twitch) via vodExpiry(platform, uploadDate). + platform: Platform; + uploadDate: string; hits: LayerHit[]; // Provenance accent of the source origin (hub mode only); undefined // single-site, so no marker renders. @@ -564,6 +573,8 @@ export default function TranscriptSearch() { date: t.date, isLivestream: t.isLivestream, ageRestricted: t.ageRestricted, + platform: t.platform, + uploadDate: t.uploadDate, hits: [], accent: accentOf(splitId(t.slug).origin), }); @@ -583,6 +594,8 @@ export default function TranscriptSearch() { date: t.date, isLivestream: t.isLivestream, ageRestricted: t.ageRestricted, + platform: t.platform, + uploadDate: t.uploadDate, hits: hitsBySlug.get(t.slug) ?? [], accent: accentOf(splitId(t.slug).origin), }); @@ -2006,6 +2019,12 @@ function ResultCard({ </span> {group.isLivestream && <LivestreamBadge />} {group.ageRestricted && <AgeRestrictedBadge />} + {(() => { + const e = vodExpiry(group.platform, group.uploadDate); + return e?.likelyExpired ? ( + <VodExpiredBadge tooltip={e.tooltip} /> + ) : null; + })()} <span className="text-xs text-muted-foreground shrink-0"> {group.channel && `${group.channel} · `} {group.date} diff --git a/common/components/badges.tsx b/common/components/badges.tsx @@ -22,3 +22,17 @@ export function AgeRestrictedBadge() { </span> ); } + +// Flags a stream VOD that is likely past its platform's retention window (Kick +// ~30d, Twitch 7–60d), so playback probably fails. `tooltip` explains the +// platform-specific retention on hover (see lib/vodExpiry). +export function VodExpiredBadge({ tooltip }: { tooltip: string }) { + return ( + <span + title={tooltip} + className={`${badgeBase} bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200`} + > + Likely expired + </span> + ); +} diff --git a/common/components/transcriptStore.ts b/common/components/transcriptStore.ts @@ -10,7 +10,9 @@ const DB_NAME = "yt-dlp-transcript-browser"; // keyPath) so cross-origin content in the hub can't collide with same-origin // content that happens to share a channelSlug/videoId. Same-origin ids equal // the bare slug, so behavior is unchanged after the one-time wipe. -const DB_VERSION = 3; +// v4 adds the optional `hlsUrl` field to cached TranscriptDetail (Kick VOD +// playback); wipe so stale entries re-fetch the authoritative page JSON. +const DB_VERSION = 4; const STORE = "transcripts"; type Mode = "pending" | "ok" | "unavailable"; diff --git a/common/controller/normalizeTranscript.ts b/common/controller/normalizeTranscript.ts @@ -28,7 +28,9 @@ import { type IndexTranscript, } from "../lib/videoStatus"; -export const CUES_FILE_VERSION = 1; +// v2: added `hlsUrl` to the summary/detail shape (Kick VOD playback), so stale +// v1 cues.json files must regenerate to pick it up. +export const CUES_FILE_VERSION = 2; export type NormalizedTranscript = TranscriptDetail & { version: number; diff --git a/common/lib/format.ts b/common/lib/format.ts @@ -3,6 +3,20 @@ export function formatDate(yyyymmdd: string): string { return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`; } +// Whole days between an upload date ("YYYYMMDD") and now. Returns null on a +// missing/malformed date. Used to flag VODs past their platform's retention +// window (see vodExpiry). Interpreted as UTC midnight to match yt-dlp's +// upload_date (a date, not a timestamp). +export function daysSince(yyyymmdd: string | undefined | null): number | null { + if (!yyyymmdd || !/^\d{8}$/.test(yyyymmdd)) return null; + const y = Number(yyyymmdd.slice(0, 4)); + const m = Number(yyyymmdd.slice(4, 6)); + const d = Number(yyyymmdd.slice(6, 8)); + const then = Date.UTC(y, m - 1, d); + if (Number.isNaN(then)) return null; + return Math.floor((Date.now() - then) / 86_400_000); +} + export function formatBytes(bytes: number): string { if (!Number.isFinite(bytes)) return "∞"; if (bytes < 1024) return `${bytes} B`; diff --git a/common/lib/platform.ts b/common/lib/platform.ts @@ -1,10 +1,11 @@ -export type Platform = "youtube" | "rumble" | "odysee" | "twitch"; +export type Platform = "youtube" | "rumble" | "odysee" | "twitch" | "kick"; export const PLATFORM_VALUES: ReadonlyArray<Platform> = [ "youtube", "rumble", "odysee", "twitch", + "kick", ]; export function detectPlatform( @@ -17,6 +18,7 @@ export function detectPlatform( if (host.endsWith("rumble.com")) return "rumble"; if (host.endsWith("odysee.com")) return "odysee"; if (host.endsWith("twitch.tv")) return "twitch"; + if (host.endsWith("kick.com")) return "kick"; } catch { /* fall through */ } @@ -32,6 +34,8 @@ export function defaultWebpageUrl(platform: Platform, id: string): string { if (platform === "rumble") return `https://rumble.com/${id}`; if (platform === "odysee") return `https://odysee.com/${id}`; if (platform === "twitch") return `https://www.twitch.tv/videos/${id}`; + // Kick's canonical id is the VOD UUID; /video/<uuid> resolves to the VOD. + if (platform === "kick") return `https://kick.com/video/${id}`; return `https://www.youtube.com/watch?v=${id}`; } diff --git a/common/lib/transcripts-server.ts b/common/lib/transcripts-server.ts @@ -21,6 +21,10 @@ export type RawMetadata = { extractor_key?: string; webpage_url?: string; webpage_url_basename?: string; + // HLS master playlist URL yt-dlp resolves for stream/VOD extractors. Kick + // VODs have no iframe embed, so we persist this to play them via hls.js in + // the export. Unsigned and CORS-open on Kick (stream.kick.com/…/master.m3u8). + manifest_url?: string; // Engagement / categorical fields used by the charts feature. Present in // yt-dlp's metadata.info.json but not all platforms/older archives carry them. timestamp?: number; @@ -64,6 +68,7 @@ export function platformFromMetadata(meta: RawMetadata): Platform { if (/^rumble/i.test(key)) return "rumble"; if (/^lbry/i.test(key)) return "odysee"; if (/^twitch/i.test(key)) return "twitch"; + if (/^kick/i.test(key)) return "kick"; return "youtube"; } @@ -110,6 +115,8 @@ export function summarize( ageRestricted: (meta.age_limit ?? 0) > 0, platform, webpageUrl: meta.webpage_url ?? defaultWebpageUrl(platform, id), + // Kick VODs play from a persisted HLS manifest (no iframe embed exists). + hlsUrl: platform === "kick" ? meta.manifest_url : undefined, }; } diff --git a/common/lib/transcripts.ts b/common/lib/transcripts.ts @@ -22,6 +22,9 @@ export type TranscriptSummary = { ageRestricted: boolean; platform: Platform; webpageUrl: string; + // HLS master-playlist URL for platforms with no iframe embed (Kick VODs). + // Undefined for every other platform. Played via react-player/file + hls.js. + hlsUrl?: string; }; export type DisplaySummary = { diff --git a/common/lib/vodExpiry.ts b/common/lib/vodExpiry.ts @@ -0,0 +1,44 @@ +import type { Platform } from "./platform"; +import { daysSince } from "./format"; + +// Retention window (days) after which a platform's stream VODs are typically +// deleted, making playback impossible. Only platforms whose VODs expire appear +// here; YouTube/Rumble/Odysee keep uploads indefinitely and are omitted. +// +// These are best-effort thresholds, not exact per-video promises: +// - Kick: free-tier VODs live in a `kick-prod-videos-30-day` bucket (~30d). +// - Twitch: past broadcasts last 7d (regular) / 14d (affiliate) / +// 60d (partner, Prime, Turbo). We can't detect a streamer's tier from +// metadata, so we use 14d (the affiliate tier) as the flag point. The +// "Likely expired" wording + the tooltip acknowledge the uncertainty for +// partner (still up to 60d) and regular (already gone at 7d) accounts. +// Tune here if the retention policy changes. +export const VOD_RETENTION_DAYS: Partial<Record<Platform, number>> = { + kick: 30, + twitch: 14, +}; + +const RETENTION_TOOLTIP: Partial<Record<Platform, string>> = { + kick: "Kick keeps VODs about 30 days after the stream, so this one has most likely been deleted. The transcript is still searchable here.", + twitch: + "Twitch keeps VODs 7–60 days depending on the streamer's account tier, so this one has most likely been deleted. The transcript is still searchable here.", +}; + +export type VodExpiryInfo = { likelyExpired: boolean; tooltip: string }; + +// Whether a VOD is likely past its platform's retention window, with a tooltip +// explaining why. Returns null for platforms whose content doesn't expire (or +// when the upload date is unknown), so callers render nothing. +export function vodExpiry( + platform: Platform, + uploadDate: string | undefined | null, +): VodExpiryInfo | null { + const retention = VOD_RETENTION_DAYS[platform]; + if (retention === undefined) return null; + const age = daysSince(uploadDate); + if (age === null) return null; + return { + likelyExpired: age > retention, + tooltip: RETENTION_TOOLTIP[platform] ?? "This VOD has most likely expired.", + }; +} diff --git a/common/package.json b/common/package.json @@ -14,6 +14,7 @@ "execa": "^9.6.1", "flexsearch": "^0.8.0", "fs-extra": "^11.3.4", + "hls.js": "^1.6.16", "lmdb": "^3.5.4", "lucide-react": "^1.16.0", "markdown-to-jsx": "^7.7.4", diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts @@ -1204,6 +1204,17 @@ export function extractVideoId(url: string): string | null { } return segs.pop() ?? null; } + if (host.endsWith("kick.com")) { + // VODs: /<channel>/videos/<uuid> or /video/<uuid>; clips: + // /<channel>/clips/<slug> or /clip/<slug>. The UUID after the marker is + // yt-dlp's native Kick id; fall back to the last segment otherwise. + const segs = u.pathname.split("/").filter(Boolean); + for (const marker of ["videos", "video", "clips", "clip"]) { + const idx = segs.indexOf(marker); + if (idx >= 0 && segs[idx + 1]) return segs[idx + 1]; + } + return segs.pop() ?? null; + } const seg = u.pathname.split("/").filter(Boolean).pop(); return seg ?? null; } catch { diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Kick VOD playback + VOD-expiry indicators.** Kick becomes a first-class platform (`Platform` union, `detectPlatform`, `platformFromMetadata` `/^kick/i`, `extractVideoId` kick branch, `defaultWebpageUrl`). Kick VODs have no iframe embed, so playback streams the HLS manifest yt-dlp resolves at download time: `summarize()` persists `manifest_url` → `hlsUrl` on the transcript summary/detail, and a new client-only `common/components/KickPlayer.tsx` plays it in a native `<video>` via the bundled **hls.js** (not react-player's file player, which loads hls.js from a CDN and would break the offline export). It exposes the same `seekTo`/`onReady`/`onProgress` handle as the YouTube player, so Kick gets full scrubbing + cue highlighting; on a fatal manifest error it falls back to an expiry notice + source link. Separately, a shared `common/lib/vodExpiry.ts` (retention: Kick 30d, Twitch 14d, tunable) drives a new `VodExpiredBadge` on search result cards for likely-deleted Kick/Twitch VODs, with a `title=` tooltip explaining each platform's retention. Cache versions bumped so stale data re-derives (`transcriptStore` `DB_VERSION` 4, `normalizeTranscript` `CUES_FILE_VERSION` 2). See `common/lib/{platform,transcripts,transcripts-server,vodExpiry,format}.ts`, `common/components/{KickPlayer,PlayerProvider,badges,TranscriptSearch}.tsx`, `common/ytdlp/runYtdlp.ts`, and `export/e2e/kick-vod.spec.ts`. - **Clicking a site on the Sites list now opens its edit page instead of bouncing back to the list.** The sidebar site selector seeds the active site into the URL (`?site=`) on mount so the scoped server pages (Dashboard, Channels, Charts, Deploy) can read it — but it was also firing on the `/sites` CRUD pages, where a mount-time `router.replace("/sites?site=<id>")` raced and clobbered the in-flight navigation to `/sites/<id>` from a list link, dumping you back on the list. The seed is now skipped on `/sites*` routes (which never consume `?site=`), so site links navigate straight to the editor; scoped-page seeding is unchanged. See `editor/app/components/SiteScopeSelect.tsx`. - **"Build static export" can now skip the data rebuild and compose from existing staging.** The `export/` build normally regenerates the pool-wide data first (its npm `prebuild` hook runs `build:data` = `build:index && build:stats && build:templates`), and the index step (heavy transcript → page-tree + LMDB processing) dominates build time. A new **Skip data rebuild (index, stats, charts)** checkbox on the Build static export control lets you rebuild a site *without* that work — it runs only `compose:site && next build` against the current `.export-index/` staging, which is exactly what you want when re-composing after a code/theme/template change or building a different site from already-staged data. It routes to a new `build:nodata` export script (same body as `build`, but a distinct name so npm's `prebuild` hook doesn't fire); the checkbox reuses the streamed-log/queue/cancel machinery of the existing managed build. Assumes a prior full build produced the staging (do a full build first if the data is stale). Only the build-only control is affected — the one-click **Build & deploy** button always does a full build. See `export/package.json` (`build:nodata`), `editor/app/deploy/buildDeployCore.ts` (`runBuildPhase` `skipData`), `editor/app/build/buildAction.ts` (`buildExportAction`), and `editor/app/deploy/components/BuildExportButton.tsx`. - **The neutral Base theme is now all-sans.** The default Base family previously inherited the shared Source **Serif** display face for the wordmark and page headings; it now uses the sans face instead, matching the pre-theme all-sans look. This is a shared token change (`html:not([data-theme])` in `common/styles/tokens.css`), so any surface on the Base family — including the editor's default — reads sans; the Archive/Selenized/Swiss families keep their own type voices. diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [0.6.0] - 2026-07-02 +- **Kick videos now play in the site's own player.** Kick VODs have no embeddable player, so before this they could only link out to kick.com. The player now streams the VOD from its original HLS source directly — with the same real scrubbing, share-at-current-timestamp, and transcript-synced cue highlighting you get on YouTube, and better than the other embed-only platforms. Nothing is proxied through kick.com at view time. +- **"Likely expired" badges for stream VODs that platforms delete.** Kick keeps VODs only ~30 days and Twitch keeps them 7–60 days (depending on the streamer's account), after which the video is gone and can't be played anywhere. Video cards for Kick/Twitch past that window now show a **Likely expired** badge (hover it for the platform's retention details). An expired Kick VOD shows a short "most likely deleted" notice with a link to the source instead of a broken player. The transcript stays fully searchable either way. + ## [0.5.1] - 2026-07-02 - **The default look is back to the original clean, neutral theme.** Public sites now open in the neutral **Base** theme — the crisp white/zinc palette (near-black ink in dark mode) with a blue accent that the site had before the themed redesign — instead of the warm brass "Archive" reading room. The wordmark and headings are all-sans to match, so the default site reads the way it used to. The Archive, Selenized, and Swiss themes are all still one click away in the theme picker, and any theme you'd previously chosen is remembered. Sites that set their own brand accent keep it. (The installable-app manifest's colors follow the new default too.) diff --git a/export/app/duplicates/DuplicatesClient.tsx b/export/app/duplicates/DuplicatesClient.tsx @@ -22,6 +22,7 @@ const PLATFORM_LABEL: Record<string, string> = { rumble: "Rumble", odysee: "Odysee", twitch: "Twitch", + kick: "Kick", }; const MATCH_LABEL: Record<DuplicateCluster["matchKind"], string> = { diff --git a/export/e2e/kick-vod.spec.ts b/export/e2e/kick-vod.spec.ts @@ -0,0 +1,157 @@ +import { expect, test, type Page } from "@playwright/test"; +import { expectModalOpen, installRoutes } from "./helpers"; + +// Kick VODs have no iframe embed — they play from a persisted HLS manifest via +// react-player/file (+ hls.js), and fall back to an expiry notice + source link +// when no playable manifest exists. These tests mock a Kick channel's transcript +// routes plus a minimal m3u8 so the playback path is exercised offline. + +const KICK_CHANNEL = "kick-channel"; +const PLAYABLE = "kick-playable-uuid"; +const EXPIRED = "kick-expired-uuid"; +const MASTER_URL = "https://mock-kick-stream.test/master.m3u8"; + +const MASTER_M3U8 = [ + "#EXTM3U", + "#EXT-X-STREAM-INF:BANDWIDTH=630000,RESOLUTION=640x360", + "360p30/playlist.m3u8", + "", +].join("\n"); + +const MEDIA_M3U8 = [ + "#EXTM3U", + "#EXT-X-VERSION:3", + "#EXT-X-TARGETDURATION:4", + "#EXT-X-PLAYLIST-TYPE:VOD", + "#EXTINF:4.0,", + "seg0.ts", + "#EXT-X-ENDLIST", + "", +].join("\n"); + +function kickDetail( + id: string, + title: string, + extra: Record<string, unknown>, +) { + return { + slug: `${KICK_CHANNEL}/${id}`, + id, + channelSlug: KICK_CHANNEL, + title, + // Old upload so the expiry helper flags it as likely expired (Kick ~30d). + uploadDate: "20200101", + date: "2020-01-01", + duration: 300, + channel: "Kick Channel", + description: "", + tags: [] as string[], + isLivestream: true, + ageRestricted: false, + platform: "kick" as const, + webpageUrl: `https://kick.com/video/${id}`, + cues: [ + { start: 5, end: 9, text: "kick alpha line" }, + { start: 50, end: 54, text: "kick beta line" }, + ], + ...extra, + }; +} + +async function fulfillJson( + route: import("@playwright/test").Route, + body: unknown, +) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +async function installKickRoutes(page: Page) { + // Reuse the shared summaries/subs routes so the shell renders, then override + // the transcript routes for the kick channel (later routes win in Playwright). + await installRoutes(page); + + await page.route(/\/transcripts\/kick-channel\/manifest\.json$/, (route) => + fulfillJson(route, { + version: 1, + channelSlug: KICK_CHANNEL, + pageCount: 1, + maxPageBytes: 8388608, + generatedAt: new Date().toISOString(), + slugToPage: { [PLAYABLE]: 0, [EXPIRED]: 0 }, + }), + ); + await page.route(/\/transcripts\/kick-channel\/page-\d+\.json$/, (route) => + fulfillJson(route, [ + kickDetail(PLAYABLE, "Playable Kick VOD", { hlsUrl: MASTER_URL }), + // No hlsUrl → the expiry/link fallback. + kickDetail(EXPIRED, "Expired Kick VOD", {}), + ]), + ); + + // Minimal HLS so hls.js parses the manifest without a fatal error. The segment + // 404s (non-fatal), which is fine — we only assert the <video> mounts. + await page.route(MASTER_URL, (route) => + route.fulfill({ + status: 200, + contentType: "application/x-mpegURL", + headers: { "access-control-allow-origin": "*" }, + body: MASTER_M3U8, + }), + ); + await page.route(/mock-kick-stream\.test\/.*playlist\.m3u8$/, (route) => + route.fulfill({ + status: 200, + contentType: "application/x-mpegURL", + headers: { "access-control-allow-origin": "*" }, + body: MEDIA_M3U8, + }), + ); + await page.route(/mock-kick-stream\.test\/.*\.ts$/, (route) => + route.fulfill({ status: 404, body: "" }), + ); +} + +test.describe("Kick VOD playback", () => { + test.beforeEach(async ({ page }) => { + await installKickRoutes(page); + }); + + test("a Kick VOD with a manifest plays via a native <video> (hls.js)", async ({ + page, + }) => { + await page.goto(`/?v=${KICK_CHANNEL}/${PLAYABLE}`); + await expectModalOpen(page); + + // react-player/file renders a native <video> for the HLS source. + await expect(page.locator("video")).toBeVisible(); + + // The expiry/link fallback is NOT shown when the manifest is playable. + await expect( + page.getByRole("link", { name: "Open on kick.com" }), + ).toHaveCount(0); + }); + + test("a Kick VOD with no manifest shows the expiry notice + source link", async ({ + page, + }) => { + await page.goto(`/?v=${KICK_CHANNEL}/${EXPIRED}`); + await expectModalOpen(page); + + // No <video> — the fallback renders instead. + await expect(page.locator("video")).toHaveCount(0); + + // Expiry badge + explanatory copy + a link to the source. + await expect(page.getByText("Likely expired")).toBeVisible(); + await expect(page.getByText(/keeps VODs about 30 days/)).toBeVisible(); + const link = page.getByRole("link", { name: "Open on kick.com" }); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute( + "href", + `https://kick.com/video/${EXPIRED}`, + ); + }); +}); diff --git a/export/package.json b/export/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.99.1", + "hls.js": "^1.6.16", "lucide-react": "^1.16.0", "next": "16.2.3", "react": "19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: fs-extra: specifier: ^11.3.4 version: 11.3.4 + hls.js: + specifier: ^1.6.16 + version: 1.6.16 lmdb: specifier: ^3.5.4 version: 3.5.4 @@ -163,6 +166,9 @@ importers: '@tanstack/react-query': specifier: ^5.99.1 version: 5.100.5(react@19.2.4) + hls.js: + specifier: ^1.6.16 + version: 1.6.16 lucide-react: specifier: ^1.16.0 version: 1.16.0(react@19.2.4) @@ -2681,6 +2687,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hls.js@1.6.16: + resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -6322,6 +6331,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hls.js@1.6.16: {} + human-signals@2.1.0: {} human-signals@8.0.1: {}