Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ce676b9
feat: add embed player wrapper
tam1m Jul 15, 2026
2f0bdad
feat: add embed player shell
tam1m Jul 15, 2026
693df17
feat: add embed route
tam1m Jul 15, 2026
9f5da95
fix: add embed layout to root
tam1m Jul 15, 2026
b2dc923
fix: show loading state while embed stream is pending
tam1m Jul 15, 2026
d8b58d8
refactor: rename some embed components
tam1m Jul 15, 2026
74a4832
fix: lint
tam1m Jul 15, 2026
1f7c336
refactor: extract embed components into separate files
tam1m Jul 16, 2026
31ed79c
fix: stabilize imported playlist behavior
Priveetee Jul 16, 2026
26b5eb6
fix: mirror watch page error handling and style
tam1m Jul 16, 2026
bc63a90
feat: support start and time_continue aliases for embed start time
tam1m Jul 16, 2026
c526cb8
feat: add Watch Later to video menus
Priveetee Jul 16, 2026
c5b0a39
test: add parseStartTime tests
tam1m Jul 16, 2026
b742eb2
fix: preserve playback position and show retry on embed errors
tam1m Jul 16, 2026
3ac5081
fix: handle keyboard focus on embed title overlay
tam1m Jul 16, 2026
bdf2ef0
fix: use PageSpinner while stream is loading in embed
tam1m Jul 16, 2026
3009c1e
refactor: detect embed playback error like the others
tam1m Jul 16, 2026
b877d75
fix: isolate embedded playback sessions
Priveetee Jul 16, 2026
147826f
fix: stabilize embed playback recovery
Priveetee Jul 16, 2026
4d78d37
test: cover embed access and retry behavior
Priveetee Jul 16, 2026
c2fc289
Merge pull request #2 from tam1m/feature/embed-1.0
Priveetee Jul 16, 2026
e908d20
feat: enable live sabr playback controls
Priveetee Jul 17, 2026
49a17c1
chore: update mse player to 0.1.32
Priveetee Jul 17, 2026
ffbfc95
chore: refresh frontend dependency lock
Priveetee Jul 17, 2026
c2b3cdf
feat: classify unavailable video access
Priveetee Jul 17, 2026
93ed12b
feat: show unavailable video thumbnails
Priveetee Jul 17, 2026
1880aec
feat: show membership badges on video cards
Priveetee Jul 17, 2026
2cb8c52
chore: update mse player to 0.1.33
Priveetee Jul 17, 2026
1809161
fix: preserve SABR playback during transitions
Priveetee Jul 18, 2026
5d68ddb
fix: use SABR for embedded YouTube playback
Priveetee Jul 18, 2026
126f7b5
chore: update mse player to 0.1.34
Priveetee Jul 18, 2026
cc79930
chore: update mse player to 0.1.35
Priveetee Jul 18, 2026
276afb5
chore: prepare frontend 1.1.0
Priveetee Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@typetype/web",
"private": true,
"version": "1.0.3",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -13,7 +13,7 @@
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"@typetype/mse": "0.1.31",
"@typetype/mse": "0.1.35",
"@vidstack/react": "1.12.13",
"dashjs": "^5.2.0",
"hls.js": "1.6.16",
Expand Down
90 changes: 90 additions & 0 deletions apps/web/src/components/embed-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { FAMILY_LIST_BLOCKED_MESSAGE } from "../lib/allow-list-error";
import { parseGeoRestriction } from "../lib/geo-restriction";
import { isMemberOnlyMessage } from "../lib/member-only";
import { type VideoAvailability, videoAvailabilityCopy } from "../lib/video-availability";
import { FlagIcon } from "./flag-icon";
import { VideoAvailabilityPoster } from "./video-availability-poster";

export const PLAYBACK_FAILED_MESSAGE =
"This video could not be played. The stream may be unavailable or unsupported.";

type EmbedErrorProps = {
message: string;
onRetry?: () => void;
heading?: string;
image?: string;
availability?: VideoAvailability;
poster?: string;
};

export function EmbedError({
message,
onRetry,
heading,
image,
availability,
poster,
}: EmbedErrorProps) {
const availabilityCopy = availability ? videoAvailabilityCopy(availability, message) : null;
const displayedMessage = availabilityCopy?.message ?? message;
const countryCode = parseGeoRestriction(displayedMessage);
const isMemberOnly = availability === "members_only" || isMemberOnlyMessage(displayedMessage);
const familyListBlocked = message === FAMILY_LIST_BLOCKED_MESSAGE;
const playbackFailed = message === PLAYBACK_FAILED_MESSAGE;
const imageSrc =
image ??
(playbackFailed
? "/sad-sigh.gif"
: familyListBlocked
? "/family-list-blocked.gif"
: isMemberOnly
? "/member-only-source.gif"
: "/error-cat.gif");
const headingText =
heading ??
availabilityCopy?.heading ??
(playbackFailed ? "Playback failed" : "Couldn't load this video");

return (
<div className="w-full h-full bg-black flex flex-col items-center justify-center gap-5 px-4">
{availability ? (
<VideoAvailabilityPoster
availability={availability}
message={message}
poster={poster}
compact
/>
) : (
<>
<img
src={imageSrc}
width={familyListBlocked ? "120" : "140"}
height={familyListBlocked ? "120" : "140"}
alt=""
className="rounded-2xl"
/>
<div className="flex flex-col items-center gap-1.5">
<p className="text-white text-base font-semibold tracking-tight">{headingText}</p>
<div className="flex items-center gap-2">
{countryCode && (
<FlagIcon code={countryCode} className="w-5 h-4 rounded-sm shrink-0" />
)}
<p className="text-fg-muted text-sm max-w-xs text-center">{displayedMessage}</p>
</div>
</div>
</>
)}
<div className="flex flex-wrap justify-center gap-3">
{onRetry && !availability && (
<button
type="button"
onClick={onRetry}
className="px-5 py-2 rounded-full bg-white hover:bg-fg text-app text-sm font-medium transition-colors cursor-pointer"
>
Retry
</button>
)}
</div>
</div>
);
}
27 changes: 27 additions & 0 deletions apps/web/src/components/embed-guest-required.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
type EmbedGuestRequiredProps = {
watchUrl: string;
};

export function EmbedGuestRequired({ watchUrl }: EmbedGuestRequiredProps) {
return (
<div className="w-full h-full bg-black flex flex-col items-center justify-center gap-5 px-4">
<img src="/error-cat.gif" width="140" height="140" alt="" className="rounded-2xl" />
<div className="flex flex-col items-center gap-1.5">
<p className="text-white text-base font-semibold tracking-tight">Embed unavailable</p>
<p className="text-fg-muted text-sm max-w-xs text-center">
This instance does not allow guest access, which is required for embedded playback.
</p>
</div>
<div className="flex flex-wrap justify-center gap-3">
<a
href={watchUrl}
target="_blank"
rel="noopener noreferrer"
className="px-5 py-2 rounded-full bg-white hover:bg-fg text-app text-sm font-medium transition-colors cursor-pointer"
>
Go to video
</a>
</div>
</div>
);
}
5 changes: 5 additions & 0 deletions apps/web/src/components/embed-loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PageSpinner } from "./page-spinner";

export function EmbedLoading() {
return <PageSpinner fullScreen />;
}
137 changes: 137 additions & 0 deletions apps/web/src/components/embed-player-shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useRef } from "react";
import { usePlayerError } from "../hooks/use-player-error";
import { usePlayerErrorResume } from "../hooks/use-player-error-resume";
import { useSabrPlaybackConfig } from "../hooks/use-sabr-playback-config";
import { useSettings } from "../hooks/use-settings";
import { useVolumeSync } from "../hooks/use-volume-sync";
import { useWatchVttAssets } from "../hooks/use-watch-layout-assets";
import { useWatchSponsorBlock } from "../hooks/use-watch-sponsorblock";
import { getOriginalAudioLocale } from "../lib/audio-track";
import { resolveEmbedAutoplay } from "../lib/embed-playback";
import type { PlaybackMode } from "../lib/playback-mode";
import { toPublicWatchParam } from "../lib/watch-url";
import type { VideoStream } from "../types/stream";
import { EmbedError, PLAYBACK_FAILED_MESSAGE } from "./embed-error";
import { EmbedVideoPlayer } from "./embed-player";

type Props = {
stream: VideoStream;
sourceUrl: string;
startTime: number;
autoplay: boolean;
sessionEnabled: boolean;
playbackMode: PlaybackMode;
};

export function EmbedPlayerShell({
stream,
sourceUrl,
startTime,
autoplay,
sessionEnabled,
playbackMode,
}: Props) {
const { settings, settingsReady, update } = useSettings({
forceAnonymous: !sessionEnabled,
});
const isLive = stream.streamType === "live_stream" || stream.streamType === "audio_live_stream";
const player = usePlayerError(stream, isLive, playbackMode);
const handleVolumeChange = useVolumeSync(update.mutate);

const positionRef = useRef(0);
const playbackIntentRef = useRef(autoplay);
const prevStreamId = useRef(stream.id);
if (prevStreamId.current !== stream.id) {
prevStreamId.current = stream.id;
playbackIntentRef.current = autoplay;
}

const { retryStartTime, handlePlayerError } = usePlayerErrorResume(
stream.id,
stream.duration,
positionRef,
player.handleError,
);

const effectiveStartTime = retryStartTime > 0 ? retryStartTime : startTime;
const effectiveAutoplay = resolveEmbedAutoplay(
player.retryKey,
playbackIntentRef.current,
autoplay,
);

const watchUrl = `/watch?v=${encodeURIComponent(toPublicWatchParam(sourceUrl))}`;

const sponsor = useWatchSponsorBlock(stream, settings);
const autoSkipSponsorBlock = sessionEnabled && settings.sponsorBlockMode !== "disabled";

const { thumbnailVtt, chaptersVtt } = useWatchVttAssets(
stream,
sponsor.segments,
settings.sponsorBlockShowChapters,
);

const sabrConfig = useSabrPlaybackConfig(
stream,
player.sabrEnabled,
settings.defaultQuality,
settings.defaultAudioLanguage,
false,
);

const playerKey = [
stream.id,
player.retryKey,
player.sabrEnabled ? "sabr" : "std",
thumbnailVtt ? "thumbs" : "no-thumbs",
chaptersVtt ? "chapters" : "no-chapters",
].join(":");

if (player.playerFailed) {
return <EmbedError message={PLAYBACK_FAILED_MESSAGE} onRetry={player.reset} />;
}

return (
<EmbedVideoPlayer
playerKey={playerKey}
src={player.manifestSrc}
sabrConfig={sabrConfig}
audioOnly={false}
title={stream.title}
poster={stream.thumbnail}
subtitles={stream.subtitles}
startTime={effectiveStartTime}
autoplay={effectiveAutoplay}
settingsReady={settingsReady}
streamType={isLive ? "live" : "on-demand"}
chaptersVtt={chaptersVtt}
thumbnailVtt={thumbnailVtt}
originalAudioLocale={getOriginalAudioLocale(stream)}
initialVolume={settings.volume}
initialMuted={settings.muted}
sponsorBlockSegments={sponsor.segments}
autoSkipSponsorBlockSegments={sessionEnabled ? sponsor.autoSkipSegments : []}
manualSkipSponsorBlockSegments={
sessionEnabled ? sponsor.manualSkipSegments : sponsor.segments
}
autoSkipSponsorBlock={autoSkipSponsorBlock}
muteSponsorBlockInsteadOfSkip={settings.sponsorBlockMuteInsteadOfSkip}
showCurrentSponsorBlockSegment={settings.sponsorBlockShowCurrentSegment}
captionStyles={settings.captionStyles}
onCaptionStylesChange={(captionStyles) => update.mutate({ captionStyles })}
onVolumeChange={handleVolumeChange}
onTimeUpdate={(positionMs) => {
positionRef.current = positionMs;
}}
onPlay={() => {
playbackIntentRef.current = true;
player.clearFailed();
}}
onPause={() => {
playbackIntentRef.current = false;
}}
onError={handlePlayerError}
watchUrl={watchUrl}
/>
);
}
30 changes: 30 additions & 0 deletions apps/web/src/components/embed-player.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { VideoPlayer } from "./video-player";
import type { VideoPlayerProps } from "./video-player-types";

type Props = VideoPlayerProps & {
playerKey?: string | number;
watchUrl?: string;
};

export function EmbedVideoPlayer({ playerKey, watchUrl, ...props }: Props) {
const overlay =
props.title && watchUrl ? (
<a
href={watchUrl}
target="_blank"
rel="noopener noreferrer"
className="absolute top-0 left-0 right-0 z-10 flex items-center gap-2 px-3 py-2 text-sm text-white/90 transition-opacity opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto group-focus-within:pointer-events-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/80"
style={{
background: "linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%)",
}}
>
<span className="truncate">{props.title}</span>
</a>
) : null;

return (
<div className="fixed inset-0 bg-black group">
<VideoPlayer key={playerKey} overlay={overlay} hideCinemaMode {...props} />
</div>
);
}
7 changes: 3 additions & 4 deletions apps/web/src/components/player-seeker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useEffect, useRef } from "react";
import { recordClientEvent } from "../lib/client-debug-log";
import { useMediaPlayer, useMediaRemote, useMediaState } from "../lib/vidstack";

const SEEK_SETTLE_DELAY_MS = 750;

function seekable(media: HTMLMediaElement, target: number) {
if (media.readyState === 0 && !Number.isFinite(media.duration)) return false;
if (media.seekable.length === 0) return true;
Expand Down Expand Up @@ -40,9 +42,6 @@ export function PlayerSeeker({ startTime }: { startTime: number }) {
return;
}
applying = true;
try {
media.currentTime = target;
} catch {}
remote.seek(target);
recordClientEvent("player.seek_apply", {
targetMs: Math.round(target * 1000),
Expand All @@ -61,7 +60,7 @@ export function PlayerSeeker({ startTime }: { startTime: number }) {
return;
}
seekMedia(media);
}, 250);
}, SEEK_SETTLE_DELAY_MS);
}

if (canPlay) remote.seek(target);
Expand Down
23 changes: 22 additions & 1 deletion apps/web/src/components/playlist-add-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePlaylists } from "../hooks/use-playlists";
import { useWatchLaterPlaylist } from "../hooks/use-watch-later-playlist";
import { watchLaterResultLabel } from "../lib/watch-later-labels";
import { toWatchLaterPayload } from "../lib/watch-later-mappers";
import type { VideoStream } from "../types/stream";
import { PlaylistRow } from "./playlist-row";

Expand All @@ -15,6 +18,7 @@ type Props = {

export function PlaylistAddDropdown({ stream, anchorEl, onClose, onSaved }: Props) {
const { query, create, addVideo, removeVideo, isInPlaylist } = usePlaylists();
const watchLater = useWatchLaterPlaylist();
const playlists = query.data ?? [];
const [newName, setNewName] = useState("");
const panelRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -98,6 +102,15 @@ export function PlaylistAddDropdown({ stream, anchorEl, onClose, onSaved }: Prop
onSaved(`Playlist "${trimmed}" created`);
}

async function handleWatchLaterToggle() {
try {
const saved = await watchLater.toggle(toWatchLaterPayload(stream));
onSaved(watchLaterResultLabel(saved));
} catch {
onSaved("Could not update Watch later");
}
}

return createPortal(
<div
ref={panelRef}
Expand All @@ -108,8 +121,16 @@ export function PlaylistAddDropdown({ stream, anchorEl, onClose, onSaved }: Prop
Save to playlist
</p>
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="border-b border-border">
<PlaylistRow
label="Watch later"
checked={watchLater.isInWatchLater(stream.id)}
disabled={watchLater.isPending}
onToggle={() => void handleWatchLaterToggle()}
/>
</div>
{playlists.length === 0 && (
<p className="text-xs text-fg-soft px-3 py-3">No playlists yet.</p>
<p className="text-xs text-fg-soft px-3 py-3">No custom playlists yet.</p>
)}
{playlists.map((playlist) => (
<PlaylistRow
Expand Down
Loading