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
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.2.0",
"version": "1.2.1",
"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.38",
"@typetype/mse": "0.1.42",
"@vidstack/react": "1.12.13",
"dashjs": "^5.2.0",
"hls.js": "1.6.16",
Expand Down
98 changes: 36 additions & 62 deletions apps/web/src/components/history-card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router";
import { X } from "lucide-react";
import { useDeArrowBranding } from "../hooks/use-dearrow";
import { formatDuration } from "../lib/format";
import { proxyImage } from "../lib/proxy";
Expand All @@ -10,28 +11,7 @@ import { HistoryChannelAvatar } from "./history-channel-avatar";
import { VideoProgressBar } from "./video-progress-bar";
import { WatchedBadge } from "./watched-badge";

function XIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={12}
height={12}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Remove"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
}

type HistoryCardProps = { item: HistoryItem; onRemove: () => void; index: number };
type HistoryCardProps = { item: HistoryItem; onRemove: () => void };

function formatWatchedAt(timestamp: number): string {
return new Date(timestamp).toLocaleString(undefined, {
Expand All @@ -43,8 +23,7 @@ function formatWatchedAt(timestamp: number): string {
});
}

export function HistoryCard({ item, onRemove, index }: HistoryCardProps) {
const delay = Math.min(index * 45, 270);
export function HistoryCard({ item, onRemove }: HistoryCardProps) {
const watched = isVideoWatched(item.progress, item.duration);
const branding = useDeArrowBranding(
item.url,
Expand All @@ -54,44 +33,39 @@ export function HistoryCard({ item, onRemove, index }: HistoryCardProps) {
);

return (
<div
className="group relative grid animate-card-pop-in grid-cols-[8.75rem_minmax(0,1fr)] gap-3 rounded-2xl border border-border bg-surface/45 p-2.5 sm:flex sm:flex-col sm:gap-2 sm:border-0 sm:bg-transparent sm:p-0"
style={{ animationDelay: `${delay}ms` }}
>
<Link to="/watch" search={watchRouteSearch(item.url)} className="block min-w-0 sm:w-full">
<div className="relative aspect-video overflow-hidden rounded-xl bg-surface-strong sm:rounded-lg">
<img
src={branding.thumbnail}
alt={branding.title}
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
decoding="async"
/>
{item.duration > 0 && (
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-xs px-1 rounded">
{formatDuration(item.duration)}
</span>
)}
{watched && (
<span className="absolute top-2 left-2">
<WatchedBadge />
</span>
)}
<VideoProgressBar progress={item.progress} duration={item.duration} alwaysVisible />
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label="Remove from history"
className="absolute top-1.5 right-1.5 rounded-full bg-black/70 p-1.5 text-white opacity-100 transition-opacity hover:bg-black/90 sm:p-1 sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
>
<XIcon />
</button>
</div>
</Link>
<div className="group relative grid grid-cols-[8.75rem_minmax(0,1fr)] gap-3 rounded-2xl border border-border bg-surface/45 p-2.5 sm:flex sm:flex-col sm:gap-2 sm:border-0 sm:bg-transparent sm:p-0">
<div className="relative min-w-0 sm:w-full">
<Link to="/watch" search={watchRouteSearch(item.url)} className="block">
<div className="relative aspect-video overflow-hidden rounded-xl bg-surface-strong sm:rounded-lg">
<img
src={branding.thumbnail}
alt={branding.title}
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
decoding="async"
/>
{item.duration > 0 && (
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-xs px-1 rounded">
{formatDuration(item.duration)}
</span>
)}
{watched && (
<span className="absolute top-2 left-2">
<WatchedBadge />
</span>
)}
<VideoProgressBar progress={item.progress} duration={item.duration} alwaysVisible />
</div>
</Link>
<button
type="button"
onClick={onRemove}
aria-label="Remove from history"
className="absolute top-1.5 right-1.5 flex h-9 w-9 items-center justify-center rounded-full bg-black/75 text-white opacity-100 shadow-sm transition-colors hover:bg-black/90 sm:h-7 sm:w-7 sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
>
<X className="h-4 w-4 sm:h-3.5 sm:w-3.5" aria-hidden="true" />
</button>
</div>
<div className="flex min-w-0 gap-2 py-0.5 sm:flex-none sm:py-0">
{item.channelUrl ? (
<ChannelRouteLink url={item.channelUrl} className="mt-0.5 hidden flex-shrink-0 sm:block">
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/sabr-mse-player-types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference";
import type { SabrPlaybackConfig } from "../lib/sabr-source";

export type SabrMsePlayerProps = {
config: SabrPlaybackConfig;
playbackRatePreference?: SabrPlaybackRatePreference;
video: HTMLVideoElement | null;
startTime: number;
autoplay: boolean;
Expand Down
38 changes: 35 additions & 3 deletions apps/web/src/components/sabr-mse-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useSabrModeSwitch } from "../hooks/use-sabr-mode-switch";
import { useSabrQualitySwitch } from "../hooks/use-sabr-quality-switch";
import { recordClientEvent } from "../lib/client-debug-log";
import { toAbsoluteApiUrl } from "../lib/env";
import { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference";
import { isAbortError } from "../lib/sabr-playback-retry";
import { cancelPendingSabrSeek, positionMs, runSabrSeek } from "../lib/sabr-player-seek";
import { registerSabrVidstackControls } from "../lib/sabr-vidstack-bridge";
Expand All @@ -13,6 +14,7 @@ import type { SabrMsePlayerProps } from "./sabr-mse-player-types";

export function SabrMsePlayer({
config,
playbackRatePreference,
video,
startTime,
autoplay,
Expand All @@ -36,6 +38,9 @@ export function SabrMsePlayer({
const autoplayConfirmedRef = useRef(false);
const seekingRef = useRef(false);
const errorReportedRef = useRef(false);
const attachedVideoRef = useRef(false);
const fallbackPlaybackRateRef = useRef(new SabrPlaybackRatePreference());
const playbackRate = playbackRatePreference ?? fallbackPlaybackRateRef.current;
const [engineReady, setEngineReady] = useState(false);
const latestConfig = useLatestValue(config);
const latestStartTime = useLatestValue(startTime);
Expand Down Expand Up @@ -77,6 +82,8 @@ export function SabrMsePlayer({
useEffect(() => {
if (!video) return;
errorReportedRef.current = false;
const replacingVideo = attachedVideoRef.current;
attachedVideoRef.current = true;
const initialConfig = latestConfig();
const engine = new TypeTypeMsePlayer(video, {
endpoint: toAbsoluteApiUrl(""),
Expand All @@ -102,7 +109,22 @@ export function SabrMsePlayer({
if (engine.isApplyingTransientMediaState()) return;
latestHandlers().onVolumeChange?.(video.volume, video.muted);
};
let playbackRateSettled = false;
const playbackRateChange = () => {
playbackRate.capture(video, !playbackRateSettled || engine.isApplyingTransientMediaState());
};
const settlePlaybackRate = () => {
if (engine.isApplyingTransientMediaState()) return;
playbackRate.apply(video, false);
playbackRateSettled = true;
};
const playEngine = () =>
engine.play().then(() => {
settlePlaybackRate();
});
playbackRate.initialize(video);
video.addEventListener("volumechange", volumeChange);
video.addEventListener("ratechange", playbackRateChange);
let autoplayStartTime = 0;
let engineLoaded = false;
const startAutoplay = () => {
Expand All @@ -118,7 +140,7 @@ export function SabrMsePlayer({
if (!latestHandlers().autoplay && !pendingPlayRef.current) return;
autoplayStartTime = video.currentTime;
autoplayStartedRef.current = true;
void engine.play().catch(() => {
void playEngine().catch(() => {
autoplayStartedRef.current = false;
});
};
Expand All @@ -128,7 +150,7 @@ export function SabrMsePlayer({
play: () => {
pendingPlayRef.current = true;
video.autoplay = true;
return engine.play();
return playEngine();
},
pause: (userInitiated = false) => {
if (!userInitiated && pendingPlayRef.current && !autoplayConfirmedRef.current) return;
Expand All @@ -148,6 +170,7 @@ export function SabrMsePlayer({
.then(() => {
engineLoaded = true;
setEngineReady(true);
if (!replacingVideo) settlePlaybackRate();
startAutoplay();
})
.catch((error: unknown) => {
Expand All @@ -162,6 +185,7 @@ export function SabrMsePlayer({
offError();
unregisterControls();
video.removeEventListener("volumechange", volumeChange);
video.removeEventListener("ratechange", playbackRateChange);
video.removeEventListener("canplay", startAutoplay);
window.clearInterval(autoplayTimer);
engine.destroy();
Expand All @@ -176,6 +200,14 @@ export function SabrMsePlayer({
video.autoplay = false;
latestHandlers().onPositionReaderChange(null);
};
}, [config.videoId, latestConfig, latestHandlers, latestStartTime, reportError, video]);
}, [
config.videoId,
latestConfig,
latestHandlers,
latestStartTime,
playbackRate,
reportError,
video,
]);
return null;
}
2 changes: 1 addition & 1 deletion apps/web/src/components/video-player-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export function VideoPlayerLayout({
icons={defaultLayoutIcons}
playbackRates={PLAYBACK_RATES}
thumbnails={thumbnailVtt}
smallLayoutWhen={false}
smallLayoutWhen={({ height }) => height < 380}
translations={{ Captions: "Subtitles" }}
slots={{
timeSlider: sabr ? (
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/video-player-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import type { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference";
import type { SabrPlaybackConfig } from "../lib/sabr-source";
import type { MediaSrc } from "../lib/vidstack";
import type { SponsorBlockSegmentItem, SubtitleItem } from "../types/api";
Expand All @@ -7,6 +8,7 @@ import type { CaptionStyles } from "../types/user";
export type VideoPlayerProps = {
src: MediaSrc;
sabrConfig?: SabrPlaybackConfig | null;
sabrPlaybackRatePreference?: SabrPlaybackRatePreference;
title?: string;
poster?: string;
streamType?: "on-demand" | "live";
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ patchVidstackProviderLoaders();
export function VideoPlayer({
src,
sabrConfig,
sabrPlaybackRatePreference,
title,
poster,
streamType = "on-demand",
Expand Down Expand Up @@ -97,7 +98,7 @@ export function VideoPlayer({
title={title}
poster={poster}
onProviderChange={sabrState.handleProviderChange}
onError={handleError}
onError={sabrConfig ? undefined : handleError}
aria-busy={sabrState.seeking}
data-sabr-seeking={sabrState.seeking ? "true" : undefined}
>
Expand All @@ -111,6 +112,7 @@ export function VideoPlayer({
{sabrConfig && (
<SabrMsePlayer
config={sabrConfig}
playbackRatePreference={sabrPlaybackRatePreference}
video={sabrState.video}
startTime={startTime}
autoplay={autoplay}
Expand Down
16 changes: 10 additions & 6 deletions apps/web/src/components/watch-layout-classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ export function getWatchLayoutClasses(cinemaMode: boolean, hasSecondaryContent:
? "pt-2 sm:pt-3 lg:flex-row lg:items-start"
: "pt-2 sm:pt-3 lg:items-center";
return {
containerClass: `flex flex-col gap-6 ${cinemaMode ? "" : standardLayout} ${anim}`,
containerClass: `watch-layout-container flex flex-col gap-6 ${
cinemaMode ? "" : standardLayout
} ${anim}`,
playerWrapClass: cinemaMode
? "overflow-hidden bg-black"
: `min-w-0 flex flex-col gap-4 ${
? "watch-player-wrap overflow-hidden bg-black"
: `watch-player-wrap min-w-0 flex flex-col gap-4 ${
hasSecondaryContent ? "flex-[2] max-w-[133.333vh]" : "mx-auto w-full max-w-[1600px]"
}`,
playerBoxClass: cinemaMode
? "relative mx-auto aspect-video w-[min(100%,calc((100svh-4.5rem)*16/9))]"
: "relative overflow-hidden rounded-lg",
playerClassName: cinemaMode ? "w-full h-full dark [--video-aspect-ratio:16/9]" : undefined,
? "watch-player-box relative mx-auto aspect-video w-[min(100%,calc((100svh-4.5rem)*16/9))]"
: "watch-player-box relative overflow-hidden rounded-lg",
playerClassName: cinemaMode
? "watch-player-surface w-full h-full dark [--video-aspect-ratio:16/9]"
: "watch-player-surface",
mediaClassName: cinemaMode ? "object-cover" : undefined,
};
}
5 changes: 4 additions & 1 deletion apps/web/src/components/watch-stage-player.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MutableRefObject, ReactNode } from "react";
import { type MutableRefObject, type ReactNode, useRef } from "react";
import { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference";
import type { SabrPlaybackConfig } from "../lib/sabr-source";
import type { MediaSrc } from "../lib/vidstack";
import type { SponsorBlockSegmentItem, SubtitleItem } from "../types/api";
Expand Down Expand Up @@ -46,6 +47,7 @@ type Props = {

export function WatchStagePlayer(props: Props) {
const settings = props.settings;
const playbackRatePreference = useRef(new SabrPlaybackRatePreference());
return (
<WatchPlayerCrossfade
audioOnly={props.audioOnly}
Expand All @@ -56,6 +58,7 @@ export function WatchStagePlayer(props: Props) {
key={props.playerKey}
src={props.manifestSrc}
sabrConfig={props.sabrConfig}
sabrPlaybackRatePreference={playbackRatePreference.current}
audioOnly={props.audioOnly}
title={props.streamTitle}
poster={props.poster}
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/hooks/use-player-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ export function usePlayerError(
setRetryKey((k) => k + 1);
}, []);

const clearFailed = useCallback(() => setPlayerFailed(false), []);
const clearFailed = useCallback(() => {
setPlayerFailed(false);
resetAutomaticSabrRecovery(sabrRecoveryRef);
}, []);
useEffect(() => {
if (playbackSourceId.length === 0) return;
setHlsFailed(false);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
@import "./styles/player-volume-controls.css";
@import "./styles/sabr-seek-state.css";
@import "./styles/video-player-mobile.css";
@import "./styles/watch-mobile-landscape.css";
@import "./styles/player-hotkeys.css";
@import "./styles/autoplay-overlay.css";
@import "./styles/pixel-scene-enter.css";
Expand Down
Loading