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 })),