diff --git a/apps/web/package.json b/apps/web/package.json index 03a72cb..d80cba6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@typetype/web", "private": true, - "version": "1.0.3", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", @@ -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", diff --git a/apps/web/src/components/embed-error.tsx b/apps/web/src/components/embed-error.tsx new file mode 100644 index 0000000..6aaebb1 --- /dev/null +++ b/apps/web/src/components/embed-error.tsx @@ -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 ( +
+ {availability ? ( + + ) : ( + <> + +
+

{headingText}

+
+ {countryCode && ( + + )} +

{displayedMessage}

+
+
+ + )} +
+ {onRetry && !availability && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/embed-guest-required.tsx b/apps/web/src/components/embed-guest-required.tsx new file mode 100644 index 0000000..47b7356 --- /dev/null +++ b/apps/web/src/components/embed-guest-required.tsx @@ -0,0 +1,27 @@ +type EmbedGuestRequiredProps = { + watchUrl: string; +}; + +export function EmbedGuestRequired({ watchUrl }: EmbedGuestRequiredProps) { + return ( +
+ +
+

Embed unavailable

+

+ This instance does not allow guest access, which is required for embedded playback. +

+
+
+ + Go to video + +
+
+ ); +} diff --git a/apps/web/src/components/embed-loading.tsx b/apps/web/src/components/embed-loading.tsx new file mode 100644 index 0000000..175bd1e --- /dev/null +++ b/apps/web/src/components/embed-loading.tsx @@ -0,0 +1,5 @@ +import { PageSpinner } from "./page-spinner"; + +export function EmbedLoading() { + return ; +} diff --git a/apps/web/src/components/embed-player-shell.tsx b/apps/web/src/components/embed-player-shell.tsx new file mode 100644 index 0000000..9716ecf --- /dev/null +++ b/apps/web/src/components/embed-player-shell.tsx @@ -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 ; + } + + return ( + update.mutate({ captionStyles })} + onVolumeChange={handleVolumeChange} + onTimeUpdate={(positionMs) => { + positionRef.current = positionMs; + }} + onPlay={() => { + playbackIntentRef.current = true; + player.clearFailed(); + }} + onPause={() => { + playbackIntentRef.current = false; + }} + onError={handlePlayerError} + watchUrl={watchUrl} + /> + ); +} diff --git a/apps/web/src/components/embed-player.tsx b/apps/web/src/components/embed-player.tsx new file mode 100644 index 0000000..74b14d2 --- /dev/null +++ b/apps/web/src/components/embed-player.tsx @@ -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 ? ( + + {props.title} + + ) : null; + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/player-seeker.tsx b/apps/web/src/components/player-seeker.tsx index 2bfeaff..1d0649e 100644 --- a/apps/web/src/components/player-seeker.tsx +++ b/apps/web/src/components/player-seeker.tsx @@ -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; @@ -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), @@ -61,7 +60,7 @@ export function PlayerSeeker({ startTime }: { startTime: number }) { return; } seekMedia(media); - }, 250); + }, SEEK_SETTLE_DELAY_MS); } if (canPlay) remote.seek(target); diff --git a/apps/web/src/components/playlist-add-dropdown.tsx b/apps/web/src/components/playlist-add-dropdown.tsx index 4a4030c..23364c0 100644 --- a/apps/web/src/components/playlist-add-dropdown.tsx +++ b/apps/web/src/components/playlist-add-dropdown.tsx @@ -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"; @@ -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(null); @@ -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(
+
+ void handleWatchLaterToggle()} + /> +
{playlists.length === 0 && ( -

No playlists yet.

+

No custom playlists yet.

)} {playlists.map((playlist) => ( void; + disabled?: boolean; }; -export function PlaylistRow({ label, checked, onToggle }: RowProps) { +export function PlaylistRow({ label, checked, onToggle, disabled }: RowProps) { return ( {reorderable && (
+ ); +} diff --git a/apps/web/src/components/video-availability-poster.tsx b/apps/web/src/components/video-availability-poster.tsx new file mode 100644 index 0000000..2a2e5cb --- /dev/null +++ b/apps/web/src/components/video-availability-poster.tsx @@ -0,0 +1,34 @@ +import type { VideoAvailability } from "../lib/video-availability"; +import { videoAvailabilityCopy } from "../lib/video-availability"; +import { VideoAvailabilityIcon } from "./video-availability-icon"; + +type Props = { + availability: VideoAvailability; + message: string; + poster?: string; + compact?: boolean; +}; + +export function VideoAvailabilityPoster({ availability, message, poster, compact = false }: Props) { + const copy = videoAvailabilityCopy(availability, message); + const frameClass = compact ? "h-full w-full" : "aspect-video w-[min(92vw,960px)] rounded-lg"; + + return ( +
+ {poster && ( + + )} +
+
+ +
+

{copy.heading}

+

{copy.message}

+
+
+
+ ); +} diff --git a/apps/web/src/components/video-block-actions-dropdown.tsx b/apps/web/src/components/video-block-actions-dropdown.tsx index 7b630f9..440a1e6 100644 --- a/apps/web/src/components/video-block-actions-dropdown.tsx +++ b/apps/web/src/components/video-block-actions-dropdown.tsx @@ -1,5 +1,6 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { watchLaterActionLabel } from "../lib/watch-later-labels"; const MARGIN = 8; @@ -7,8 +8,11 @@ type Props = { anchorEl: HTMLElement | null; onClose: () => void; onSaveToPlaylist?: () => void; + onToggleWatchLater?: () => void; onToggleVideoBlock?: () => void; onToggleChannelBlock?: () => void; + watchLaterSaved?: boolean; + watchLaterPending?: boolean; videoBlocked?: boolean; channelBlocked?: boolean; }; @@ -17,8 +21,11 @@ export function VideoBlockActionsDropdown({ anchorEl, onClose, onSaveToPlaylist, + onToggleWatchLater, onToggleVideoBlock, onToggleChannelBlock, + watchLaterSaved = false, + watchLaterPending = false, videoBlocked, channelBlocked, }: Props) { @@ -68,6 +75,20 @@ export function VideoBlockActionsDropdown({ style={panelStyle} className="fixed z-50 w-56 overflow-hidden rounded-lg border border-border-strong bg-surface shadow-2xl" > + {onToggleWatchLater && ( + + )} {onSaveToPlaylist && (