Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
61 changes: 61 additions & 0 deletions apps/web/src/components/embed-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { FAMILY_LIST_BLOCKED_MESSAGE } from "../lib/allow-list-error";
import { parseGeoRestriction } from "../lib/geo-restriction";
import { isMemberOnlyMessage } from "../lib/member-only";
import { FlagIcon } from "./flag-icon";

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;
};

export function EmbedError({ message, onRetry, heading, image }: EmbedErrorProps) {
const countryCode = parseGeoRestriction(message);
const isMemberOnly = isMemberOnlyMessage(message);
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 ?? (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">
<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">{message}</p>
</div>
</div>
<div className="flex flex-wrap justify-center gap-3">
{onRetry && (
<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
4 changes: 3 additions & 1 deletion apps/web/src/components/video-player-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SabrTimeSlider } from "./sabr-time-slider";

type Props = {
audioOnly?: boolean;
hideCinemaMode?: boolean;
audioUsesVideoProvider?: boolean;
sabr?: boolean;
sabrVideo?: HTMLVideoElement | null;
Expand All @@ -24,6 +25,7 @@ type Props = {

export function VideoPlayerLayout({
audioOnly = false,
hideCinemaMode = false,
audioUsesVideoProvider = false,
sabr = false,
sabrVideo = null,
Expand Down Expand Up @@ -119,7 +121,7 @@ export function VideoPlayerLayout({
beforeFullscreenButton: (
<>
<PlayerVolumeControl />
<CinemaModeControl />
{!hideCinemaMode && <CinemaModeControl />}
</>
),
}}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/video-player-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type VideoPlayerProps = {
settingsReady?: boolean;
autoplay?: boolean;
audioOnly?: boolean;
hideCinemaMode?: boolean;
originalAudioLocale?: string | null;
overlay?: ReactNode;
captionStyles?: CaptionStyles;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function VideoPlayer({
settingsReady = false,
autoplay = false,
audioOnly = false,
hideCinemaMode = false,
originalAudioLocale,
overlay,
captionStyles,
Expand Down Expand Up @@ -137,6 +138,7 @@ export function VideoPlayer({
{overlay}
<VideoPlayerLayout
audioOnly={audioOnly}
hideCinemaMode={hideCinemaMode}
audioUsesVideoProvider={audioOnly && viewType === "video"}
sabr={Boolean(sabrConfig)}
sabrVideo={sabrState.video}
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/hooks/use-player-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { bilibiliVariantCount } from "../lib/bilibili-manifest";
import { recordClientEvent } from "../lib/client-debug-log";
import { sanitizeVideoContext } from "../lib/debug-sanitize";
import { isIosDevice } from "../lib/ios-device";
import type { PlaybackMode } from "../lib/playback-mode";
import { detectProvider } from "../lib/provider";
import { claimAutomaticSabrRecovery, resetAutomaticSabrRecovery } from "../lib/sabr-error-recovery";
import {
Expand Down Expand Up @@ -30,12 +31,17 @@ type UsePlayerErrorReturn = {
seekStartTime: number | null;
};

export function usePlayerError(stream: VideoStream, isLive: boolean): UsePlayerErrorReturn {
export function usePlayerError(
stream: VideoStream,
isLive: boolean,
playbackModeOverride?: PlaybackMode,
): UsePlayerErrorReturn {
const debugVideo = sanitizeVideoContext(stream.id) ?? "unknown";
const provider = detectProvider(stream.id);
const iosDevice = isIosDevice();
const { data: instance } = useInstance();
const { playbackMode } = usePlaybackMode();
const { playbackMode: storedPlaybackMode } = usePlaybackMode();
const playbackMode = playbackModeOverride ?? storedPlaybackMode;
const playbackSourceId = stream.id.length === 0 ? "" : `${stream.id}:${playbackMode}`;
const preferServerManifests = instance?.guestAllowed !== false;
const legacyDashPair = hasLegacyDashPair(stream);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/hooks/use-session-activity-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { useAuth } from "./use-auth";

const ACTIVITY_INTERVAL_MS = 60_000;

export function useSessionActivityReporting() {
export function useSessionActivityReporting(allowed = true) {
const { status } = useAuth();
const enabled = status === "authenticated";
const enabled = allowed && status === "authenticated";

useEffect(() => {
if (!enabled) return;
Expand Down
Loading