diff --git a/js/app/components/mobile/desktop-ui/bottom-controls.tsx b/js/app/components/mobile/desktop-ui/bottom-controls.tsx
index 9351659ab..1ac29fa35 100644
--- a/js/app/components/mobile/desktop-ui/bottom-controls.tsx
+++ b/js/app/components/mobile/desktop-ui/bottom-controls.tsx
@@ -1,6 +1,8 @@
import {
Button,
+ PlayerStatus,
PlayerUI,
+ Text,
View,
useDanmuEnabled,
useDanmuUnlocked,
@@ -14,7 +16,9 @@ import {
ChevronRight,
Fullscreen,
Minimize,
+ Pause,
PictureInPicture2,
+ Play,
} from "lucide-react-native";
import { Platform, Pressable } from "react-native";
import { useIsSidebarCollapsed } from "store/hooks";
@@ -116,6 +120,16 @@ function CollapseChatButton({
);
}
+function formatTime(seconds: number): string {
+ const s = Math.floor(seconds);
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ if (h > 0) return `${h}:${pad(m)}:${pad(sec)}`;
+ return `${m}:${pad(sec)}`;
+}
+
export function BottomControlBar({
ingest,
pipSupported,
@@ -125,41 +139,76 @@ export function BottomControlBar({
showChat,
setShowChat,
}: BottomControlBarProps) {
- let { theme } = useTheme();
- const fullscreen = usePlayerStore((state) => state.fullscreen);
- const setFullscreen = usePlayerStore((state) => state.setFullscreen);
- const danmuUnlocked = useDanmuUnlocked();
- const danmuEnabled = useDanmuEnabled();
- const setDanmuEnabled = useSetDanmuEnabled();
+ const th = useTheme();
const sidebarCollapsed = useIsSidebarCollapsed();
+ const playbackMode = usePlayerStore((x) => x.mode);
+ const togglePlayPause = usePlayerStore((x) => x.togglePlayPause);
+
+ const playTime = usePlayerStore((x) => x.playTime);
+ const duration = usePlayerStore((x) => x.duration);
+
+ const status = usePlayerStore((x) => x.status);
+ const PlayPause = status === PlayerStatus.PLAYING ? Pause : Play;
return (
-
-
-
-
+
+
+
+
+ {playbackMode === "vod" && (
+
+
+
+ )}
+
+ {playbackMode === "vod" && (
+
+
+ {formatTime(playTime)}
+
+ /
+
+ {formatTime(duration)}
+
+
+ )}
+
-
- {pipSupported && (
-
- )}
-
- {ingest === null && (
-
- )}
-
- {setShowChat && (
-
- )}
+
+ {pipSupported && (
+
+ )}
+
+ {ingest === null && (
+
+ )}
+
+ {setShowChat && (
+
+ )}
+
);
diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx
index cd1beab13..7622679b8 100644
--- a/js/app/components/mobile/player.tsx
+++ b/js/app/components/mobile/player.tsx
@@ -31,7 +31,6 @@ import {
} from "react-native";
import Reanimated, {
useAnimatedStyle,
- useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
@@ -81,14 +80,8 @@ function PlayerWithProvider(
const { top: safeTop } = useSafeAreaInsets();
const segDims = useSegmentDimensions();
const isPortrait = screenHeight > screenWidth;
- const portraitFadeOpacity = useSharedValue(1);
- const portraitVideoTranslateY = useDerivedValue(
- () => (portraitFadeOpacity.value - 1) * 20,
- );
- const portraitVideoStyle = useAnimatedStyle(() => ({
- transform: [{ translateY: (portraitFadeOpacity.value - 1) * 20 }],
- }));
const isPortraitLandscapeCase =
+ Platform.OS !== "web" &&
isPortrait &&
segDims.width > segDims.height &&
!shouldShowChatSidePanel &&
@@ -251,25 +244,19 @@ function PlayerWithProvider(
flex: 1,
width: "100%",
height: "100%",
- paddingTop:
- isPortraitLandscapeCase && Platform.OS !== "web"
- ? 54
- : undefined,
+ paddingTop: isPortraitLandscapeCase ? 54 : undefined,
},
]}
>
-
-
+
{isPortraitLandscapeCase ? (
<>
-
+
{!showUnavailable && (
-
+
)}
>
) : shouldShowChatSidePanel ? (
) : (
!showUnavailable &&
@@ -309,53 +287,6 @@ function PlayerWithProvider(
);
}
-function MobileUiOverlay({
- showChat,
- setShowChat,
- isPortraitLandscapeCase,
- mode,
-}: {
- showChat: boolean;
- setShowChat: (show: boolean) => void;
- isPortraitLandscapeCase: boolean;
- mode?: string;
-}) {
- const { width, height } = usePlayerDimensions();
- const aspectRatio = width > 0 && height > 0 ? width / height : 16 / 9;
- const isLandscape = aspectRatio > 1;
- const showFullDesktopMode = aspectRatio > 1 && width > 1200;
- const fullscreen = usePlayerStore((x) => x.fullscreen);
- const { isPlayerRatioGreater: segIsLandscape } = useSegmentDimensions();
-
- if (
- isPortraitLandscapeCase ||
- mode === "vod" ||
- showFullDesktopMode ||
- fullscreen ||
- (!isLandscape && !segIsLandscape)
- ) {
- return null;
- }
-
- return (
-
-
-
- );
-}
-
export function PlayerInner(
props: Partial & {
showChat: boolean;
@@ -503,7 +434,7 @@ export function PlayerInner(
{showFullDesktopMode || fullscreen ? (
) : (
- (isLandscape || !!props.ingest) && (
+ (isLandscape || props.mode === "vod") && (
)}
- {props.mode === "vod" && (
-
-
-
-
- )}
state.mode);
const muteWasForced = usePlayerStore((state) => state.muteWasForced);
const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced);
const [playerIsReady, setPlayerIsReady] = useState(false);
@@ -259,6 +260,20 @@ export function MobileUi({
/>
)}
+ {mode === "vod" && (
+
+
+
+
+ )}
+
String(n).padStart(2, "0");
- if (h > 0) return `${h}:${pad(m)}:${pad(sec)}`;
- return `${m}:${pad(sec)}`;
-}
+import { View } from "../../ui/index";
export function SeekBar() {
const mode = usePlayerStore((x) => x.mode);
@@ -30,11 +20,9 @@ export function SeekBar() {
const thumbHovered = useSharedValue(0);
const thumbAnimStyle = useAnimatedStyle(() => ({
- boxShadow: `0 2px 10px rgba(0, 0, 0, ${withSpring(thumbHovered.value === 1 ? 1 : 0.18)})`,
transform: [
- { scaleY: withSpring(thumbHovered.value === 1 ? 1 : 0.18) },
{
- scaleX: withSpring(thumbHovered.value === 1 ? 1 : 0.35),
+ scale: withSpring(thumbHovered.value === 1 ? 1 : 0.18),
},
],
}));
@@ -69,7 +57,7 @@ export function SeekBar() {
style={{
flex: 1,
height: 90,
- paddingBottom: 24,
+ paddingBottom: 10,
}}
>
-
-
- {formatTime(displayTime)}
-
-
- {formatTime(duration)}
-
-
);
}
diff --git a/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx b/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
index ff223f638..007c926cd 100644
--- a/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
+++ b/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
@@ -1,7 +1,3 @@
-import {
- AppBskyActorDefs,
- ComAtprotoModerationCreateReport,
-} from "@atproto/api";
import { useRootContext } from "@rn-primitives/dropdown-menu";
import { Image } from "expo-image";
import { Cog } from "lucide-react-native";
@@ -15,15 +11,11 @@ import Animated, {
import {
ContentRights,
ContentWarnings,
- UpdateStreamTitleDialog,
formatHandle,
formatHandleWithAt,
useAvatars,
- useCanModerate,
- useLivestream,
useLivestreamInfo,
useStreamplaceStore,
- useUpdateLivestreamRecord,
zero,
} from "../../..";
import { useLivestreamStore } from "../../../livestream-store";
@@ -48,10 +40,6 @@ import {
useTheme,
} from "../../ui";
-type ReportSubject =
- | ComAtprotoModerationCreateReport.InputSchema["subject"]
- | null;
-
export function ContextMenu({
dropdownPortalContainer,
}: {
@@ -60,7 +48,11 @@ export function ContextMenu({
const th = useTheme();
const quality = usePlayerStore((x) => x.selectedRendition);
const setQuality = usePlayerStore((x) => x.setSelectedRendition);
- const qualities = useLivestreamStore((x) => x.renditions);
+ const mode = usePlayerStore((x) => x.mode);
+ const vodLevels = usePlayerStore((x) => x.vodLevels);
+ const playingVODRendition = usePlayerStore((x) => x.playingVODRendition);
+ const liveRenditions = useLivestreamStore((x) => x.renditions);
+ const qualities = mode === "vod" ? vodLevels : liveRenditions;
const protocol = usePlayerStore((x) => x.protocol);
const setProtocol = usePlayerStore((x) => x.setProtocol);
@@ -111,12 +103,6 @@ export function ContextMenu({
const segment = useLivestreamStore((x) => x.segment);
const [isOpen, setIsOpen] = useState(false);
- const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false);
-
- const modPermissions = useCanModerate(profile?.did);
- const { updateLivestream, isLoading: isUpdateTitleLoading } =
- useUpdateLivestreamRecord();
- const livestreamRecord = useLivestream();
// Get content rights from the latest segment
const contentRights = segment?.contentRights;
@@ -152,273 +138,198 @@ export function ContextMenu({
// rerender when dropdown portal container changes so we swap portals 'seamlessly'
return (
- <>
-
-
-
-
-
-
-
- {Platform.OS !== "web" && (
-
-
- {profile?.did && avatars[profile?.did]?.avatar && (
-
+
+
+
+
+
+
+ {Platform.OS !== "web" && (
+
+
+ {profile?.did && avatars[profile?.did]?.avatar && (
+
+ )}
+
+
+ {
+ if (profile?.handle) {
+ const url = `https://bsky.app/profile/${formatHandle(profile)}`;
+ Linking.openURL(url);
+ }
}}
- style={{ width: 42, height: 42, borderRadius: 999 }}
- contentFit="cover"
- />
- )}
-
-
- {
- if (profile?.handle) {
- const url = `https://bsky.app/profile/${formatHandle(profile)}`;
- Linking.openURL(url);
- }
- }}
- >
- {profile && formatHandleWithAt(profile)}
-
- {/*{did && profile && (
+ {profile && formatHandleWithAt(profile)}
+
+ {/*{did && profile && (
)}*/}
-
-
- {ls?.record.title || "Stream Title"}
-
-
-
- {
- try {
- if (graphManager.isFollowing) {
- await graphManager.unfollow();
- } else {
- await graphManager.follow();
- }
- } catch (err) {
- console.error("Follow/unfollow error:", err);
- }
- }}
- >
- {graphManager.isLoading
- ? "Loading..."
- : graphManager.isFollowing
- ? "Unfollow"
- : "Follow"}
+ {ls?.record.title || "Stream Title"}
-
-
- {
- if (profile?.handle) {
- const url = `https://bsky.app/profile/${formatHandle(profile)}`;
- Linking.openURL(url);
+
+
+
+ {
+ try {
+ if (graphManager.isFollowing) {
+ await graphManager.unfollow();
+ } else {
+ await graphManager.follow();
}
- }}
+ } catch (err) {
+ console.error("Follow/unfollow error:", err);
+ }
+ }}
+ >
+
- View Profile on Bluesky
-
-
- )}
-
- {Platform.OS !== "web" && modPermissions.canManageLivestream && (
-
-
-
- )}
-
-
-
-
-
- Quality
-
- {quality === "source"
- ? `Source${resolutionDisplay ? " " + resolutionDisplay + "\n" : ", "}`
- : quality === "audio"
- ? `Audio Only\n`
- : quality}
- {lowLatency ? "Low Latency" : ""}
-
-
-
-
-
-
-
- Source {resolutionDisplay}
-
- {qualities.map((r) => (
-
-
- {r.name === "audio" ? "Audio Only" : r.name}
-
-
- ))}
-
-
-
- setLowLatency(!lowLatency)}
- >
- Low Latency
-
-
-
-
-
-
-
- setShowDebugInfo(!debugInfo)}
+ {graphManager.isLoading
+ ? "Loading..."
+ : graphManager.isFollowing
+ ? "Unfollow"
+ : "Follow"}
+
+
+
+ {
+ if (profile?.handle) {
+ const url = `https://bsky.app/profile/${formatHandle(profile)}`;
+ Linking.openURL(url);
+ }
+ }}
>
- Show Debug Info
-
-
-
-
-
+ View Profile on Bluesky
+
-
- {contentWarnings && contentWarnings.length > 0 && (
-
-
- Stream may contain
+ )}
+
+
+
+
+
+ Quality
+
+ {quality === "source"
+ ? mode === "vod"
+ ? `Auto${playingVODRendition ? ` (${playingVODRendition})` : ""}\n`
+ : `Source${resolutionDisplay ? " " + resolutionDisplay + "\n" : ", "}`
+ : quality === "audio"
+ ? `Audio Only\n`
+ : quality}
+ {mode !== "vod" && lowLatency ? "Low Latency" : ""}
-
- )}
- {contentRights && Object.keys(contentRights).length > 0 && (
-
- )}
-
-
-
- {showUpdateTitleDialog && (
- setShowUpdateTitleDialog(false)}
- />
- )}
- >
- );
-}
-
-function UpdateStreamTitleItem({
- setShowUpdateTitleDialog,
- isUpdateTitleLoading,
- livestream,
-}: {
- setShowUpdateTitleDialog: (show: boolean) => void;
- isUpdateTitleLoading: boolean;
- livestream: any;
-}) {
- const { onOpenChange } = useRootContext();
- return (
- {
- onOpenChange?.(false);
- setShowUpdateTitleDialog(true);
- }}
- disabled={isUpdateTitleLoading || !livestream}
- >
-
- {isUpdateTitleLoading ? "Updating..." : "Update stream title"}
-
-
- );
-}
-
-function ReportUserItem({
- profile,
- setReportModalOpen,
- setReportSubject,
-}: {
- profile: AppBskyActorDefs.ProfileViewBasic | null;
- setReportModalOpen: (open: boolean) => void;
- setReportSubject: (subject: ReportSubject) => void;
-}) {
- const { onOpenChange } = useRootContext();
- return (
- {
- if (!profile?.did) return;
- onOpenChange?.(false);
- setReportModalOpen(true);
- setReportSubject({
- $type: "com.atproto.admin.defs#repoRef",
- did: profile.did,
- });
- }}
- disabled={!profile?.did}
- >
- Report User...
-
+
+
+
+
+
+ Source {resolutionDisplay}
+
+ {qualities.map((r) => (
+
+ {r.name === "audio" ? "Audio Only" : r.name}
+
+ ))}
+
+
+ {mode !== "vod" && (
+ <>
+
+ setLowLatency(!lowLatency)}
+ >
+ Low Latency
+
+
+
+ >
+ )}
+
+
+
+
+ setShowDebugInfo(!debugInfo)}
+ >
+ Show Debug Info
+
+
+
+
+
+
+ {contentWarnings && contentWarnings.length > 0 && (
+
+
+ Stream may contain
+
+
+
+ )}
+ {contentRights && Object.keys(contentRights).length > 0 && (
+
+ )}
+
+
+
);
}
diff --git a/js/components/src/components/mobile-player/ui/vod-controls.tsx b/js/components/src/components/mobile-player/ui/vod-controls.tsx
index fc448d867..97388ee57 100644
--- a/js/components/src/components/mobile-player/ui/vod-controls.tsx
+++ b/js/components/src/components/mobile-player/ui/vod-controls.tsx
@@ -18,7 +18,7 @@ import {
export function VodControls() {
const mode = usePlayerStore((x) => x.mode);
const status = usePlayerStore((x) => x.status);
- const videoRef = usePlayerStore((x) => x.videoRef);
+ const togglePlayPause = usePlayerStore((x) => x.togglePlayPause);
const quality = usePlayerStore((x) => x.selectedRendition);
const setQuality = usePlayerStore((x) => x.setSelectedRendition);
const liveRenditions = useLivestreamStore((x) => x.renditions);
@@ -30,21 +30,6 @@ export function VodControls() {
const isPlaying = status === PlayerStatus.PLAYING;
- const togglePlayPause = () => {
- if (
- videoRef &&
- typeof videoRef === "object" &&
- "current" in videoRef &&
- videoRef.current
- ) {
- if (videoRef.current.paused) {
- videoRef.current.play();
- } else {
- videoRef.current.pause();
- }
- }
- };
-
return (
x.protocol);
+ const inProto = usePlayerStore((x) => x.protocol);
+ const selectedRendition = usePlayerStore((x) => x.selectedRendition);
+ const src = usePlayerStore((x) => x.src);
const ingest = usePlayerStore((x) => x.ingestConnectionState) != null;
+ const { protocol } = srcToUrl({ src, selectedRendition }, inProto);
return (
@@ -68,6 +71,7 @@ export function NativeVideo(props?: {
}) {
const videoRef = useRef(null);
const protocol = usePlayerStore((x) => x.protocol);
+ const mode = usePlayerStore((x) => x.mode);
const selectedRendition = usePlayerStore((x) => x.selectedRendition);
const src = usePlayerStore((x) => x.src);
@@ -82,6 +86,14 @@ export function NativeVideo(props?: {
const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth);
const setPlayerHeight = usePlayerStore((x) => x.setPlayerHeight);
+ const setPlayTime = usePlayerStore((x) => x.setPlayTime);
+ const setDuration = usePlayerStore((x) => x.setDuration);
+ const setBufferedEnd = usePlayerStore((x) => x.setBufferedEnd);
+ const playTime = usePlayerStore((x) => x.playTime);
+ const setTogglePlayPause = usePlayerStore((x) => x.setTogglePlayPause);
+ const status = usePlayerStore((x) => x.status);
+ const statusRef = useRef(status);
+ const lastReportedTimeRef = useRef(0);
// State for live dimensions
const [dimensions, setDimensions] = useState<{
@@ -106,15 +118,14 @@ export function NativeVideo(props?: {
}, [setStatus]);
const player = useVideoPlayer(url, (player) => {
- player.addListener("playingChange", (newIsPlaying) => {
- console.log("playingChange", newIsPlaying);
- if (newIsPlaying) {
+ player.addListener("playingChange", ({ isPlaying }) => {
+ if (isPlaying) {
setStatus(PlayerStatus.PLAYING);
} else {
setStatus(PlayerStatus.WAITING);
}
});
- player.loop = true;
+ player.loop = mode !== "vod";
player.muted = muted;
player.play();
});
@@ -127,6 +138,51 @@ export function NativeVideo(props?: {
player.volume = volume;
}, [volume, player]);
+ useEffect(() => {
+ statusRef.current = status;
+ }, [status]);
+
+ useEffect(() => {
+ player.loop = mode !== "vod";
+ }, [player, mode]);
+
+ useEffect(() => {
+ setTogglePlayPause(() => {
+ if (statusRef.current === PlayerStatus.PLAYING) {
+ player.pause();
+ } else {
+ player.play();
+ }
+ });
+ return () => setTogglePlayPause(() => {});
+ }, [player, setTogglePlayPause]);
+
+ useEffect(() => {
+ if (mode !== "vod") return;
+ const interval = setInterval(() => {
+ const currentTime = player.currentTime;
+ const duration = player.duration;
+ lastReportedTimeRef.current = currentTime;
+ setPlayTime(currentTime);
+ if (isFinite(duration) && duration > 0) {
+ setDuration(duration);
+ }
+ const buffered = (player as any).bufferedPosition;
+ if (typeof buffered === "number" && buffered > 0) {
+ setBufferedEnd(buffered);
+ }
+ }, 250);
+ return () => clearInterval(interval);
+ }, [mode, player, setPlayTime, setDuration, setBufferedEnd]);
+
+ useEffect(() => {
+ if (mode !== "vod") return;
+ const diff = Math.abs(playTime - lastReportedTimeRef.current);
+ if (diff > 1.0) {
+ player.seekBy(playTime - player.currentTime);
+ }
+ }, [playTime, mode, player]);
+
useEffect(() => {
const subs = (
[
@@ -146,9 +202,8 @@ export function NativeVideo(props?: {
});
subs.push(
- player.addListener("playingChange", (newIsPlaying) => {
- console.log("playingChange", newIsPlaying);
- if (newIsPlaying) {
+ player.addListener("playingChange", ({ isPlaying }) => {
+ if (isPlaying) {
setStatus(PlayerStatus.PLAYING);
} else {
setStatus(PlayerStatus.WAITING);
diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx
index 1deffafc0..768d3b4c6 100644
--- a/js/components/src/components/mobile-player/video.tsx
+++ b/js/components/src/components/mobile-player/video.tsx
@@ -341,6 +341,9 @@ export function HLSPlayer(props: VideoProps) {
const localRef = useRef(null);
const hlsRef = useRef(null);
const setVodLevels = usePlayerStore((x) => x.setVodLevels);
+ const setPlayingVODRendition = usePlayerStore(
+ (x) => x.setPlayingVODRendition,
+ );
const selectedRendition = usePlayerStore((x) => x.selectedRendition);
const mode = usePlayerStore((x) => x.mode);
@@ -382,10 +385,18 @@ export function HLSPlayer(props: VideoProps) {
);
}
});
+ hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => {
+ const l = hls.levels[data.level];
+ if (!l) return;
+ const name =
+ l.height > 0 ? `${l.height}p` : `${Math.round(l.bitrate / 1000)}k`;
+ setPlayingVODRendition(name);
+ });
return () => {
hls.stopLoad();
hlsRef.current = null;
setVodLevels([]);
+ setPlayingVODRendition(null);
};
} else if (localRef.current.canPlayType("application/vnd.apple.mpegurl")) {
localRef.current.src = props.url;
diff --git a/js/components/src/player-store/player-state.tsx b/js/components/src/player-store/player-state.tsx
index e01557b50..f824fea3c 100644
--- a/js/components/src/player-store/player-state.tsx
+++ b/js/components/src/player-store/player-state.tsx
@@ -41,6 +41,10 @@ export interface PlayerState {
/** Quality levels parsed from HLS manifest (VOD only) */
vodLevels: Array<{ name: string }>;
setVodLevels: (levels: Array<{ name: string }>) => void;
+
+ /** Actual rendition name currently playing when in auto/source mode (web HLS only) */
+ playingVODRendition: string | null;
+ setPlayingVODRendition: (name: string | null) => void;
protocol: PlayerProtocol;
setProtocol: (protocol: PlayerProtocol) => void;
@@ -119,6 +123,9 @@ export interface PlayerState {
/** Function to set the Picture-in-Picture action */
setPipAction: (action: (() => void) | undefined) => void;
+ togglePlayPause: () => void;
+ setTogglePlayPause: (fn: () => void) => void;
+
/** Player element width (CSS value or number) */
playerWidth?: string | number;
/** Function to set the player width */
diff --git a/js/components/src/player-store/player-store.tsx b/js/components/src/player-store/player-store.tsx
index 46fd58fa7..0d6aafc8b 100644
--- a/js/components/src/player-store/player-store.tsx
+++ b/js/components/src/player-store/player-store.tsx
@@ -29,6 +29,9 @@ export const makePlayerStore = (id?: string): StoreApi => {
setBufferedEnd: (bufferedEnd) => set(() => ({ bufferedEnd })),
vodLevels: [],
setVodLevels: (vodLevels) => set(() => ({ vodLevels })),
+ playingVODRendition: null,
+ setPlayingVODRendition: (playingVODRendition) =>
+ set(() => ({ playingVODRendition })),
selectedRendition: "source",
setSelectedRendition: (rendition: string) =>
@@ -126,6 +129,20 @@ export const makePlayerStore = (id?: string): StoreApi => {
setPipAction: (action: (() => void) | undefined) =>
set(() => ({ pipAction: action })),
+ togglePlayPause: () =>
+ set((state) => {
+ const ref = state.videoRef;
+ if (ref && typeof ref === "object" && "current" in ref && ref.current) {
+ if (ref.current.paused) {
+ ref.current.play();
+ } else {
+ ref.current.pause();
+ }
+ }
+ return {};
+ }),
+ setTogglePlayPause: (fn) => set(() => ({ togglePlayPause: fn })),
+
// Player element width/height setters for global sync
playerWidth: undefined,
setPlayerWidth: (playerWidth: number) => set(() => ({ playerWidth })),