diff --git a/js/app/components/edit-livestream.tsx b/js/app/components/edit-livestream.tsx index ee3b9d1eb..4287a3ae1 100644 --- a/js/app/components/edit-livestream.tsx +++ b/js/app/components/edit-livestream.tsx @@ -11,11 +11,7 @@ import { ScrollView } from "react-native"; import { useAppDispatch, useAppSelector } from "store/hooks"; import { Button, H3, Label, Paragraph, Text, TextArea, View } from "tamagui"; -export default function UpdateLivestream({ - playerId, -}: { - playerId: string | null; -}) { +export default function UpdateLivestream() { const dispatch = useAppDispatch(); const toast = useToastController(); const userIsLive = useLiveUser(); @@ -42,16 +38,16 @@ export default function UpdateLivestream({ }, [newLivestream?.error]); const disabled = !userIsLive || loading || title === ""; - if (!playerId) { - return ( - - - Couldn't get the player ID. You may not have created an initial - livestream record. - - - ); - } + // if (!playerId) { + // return ( + // + // + // Couldn't get the player ID. You may not have created an initial + // livestream record. + // + // + // ); + // } const handleSubmit = async () => { setLoading(true); diff --git a/js/app/components/player/controls.tsx b/js/app/components/player/controls.tsx index 1c50d00c7..e3f91ea6d 100644 --- a/js/app/components/player/controls.tsx +++ b/js/app/components/player/controls.tsx @@ -24,7 +24,6 @@ import { Countdown } from "components/countdown"; import Loading from "components/loading/loading"; import Viewers from "components/viewers"; import { userMute } from "features/streamplace/streamplaceSlice"; -import usePlatform from "hooks/usePlatform"; import { Dispatch, Fragment, @@ -33,7 +32,7 @@ import { useRef, useState, } from "react"; -import { Animated, Easing, Pressable } from "react-native"; +import { Animated, Platform, Pressable } from "react-native"; import { useAppDispatch } from "store/hooks"; import { PlaceStreamDefs } from "streamplace"; import { @@ -54,227 +53,209 @@ import { XStack, YGroup, } from "tamagui"; -import { PlayerProps, PROTOCOL_HLS, PROTOCOL_WEBRTC } from "./props"; + +const PROTOCOL_HLS = "hls"; +// const PROTOCOL_PROGRESSIVE_MP4 = "progressive-mp4"; +// const PROTOCOL_PROGRESSIVE_WEBM = "progressive-webm"; +const PROTOCOL_WEBRTC = "webrtc"; const Bar = (props) => ( ); const Part = (props) => ( - ); -const VolumeSlider = ({ - volume, - setVolume, - muted, - setMuted, - showControls, -}: { +const VolumeSlider = (props: { volume: number; setVolume: (volume: number) => void; muted: boolean; - setMuted: (muted: boolean) => void; showControls: boolean; + setMuted: (muted: boolean) => void; }) => { - const [hovered, setHovered] = useState(false); - const [sliderValue, setSliderValue] = useState(volume); - const { isWeb } = usePlatform(); - const sliderRef = useRef(null); - const iconRef = useRef(null); - const lastVolumeRef = useRef(volume || 1); - const fadeAnim = useRef(new Animated.Value(0)).current; - const hideTimer = useRef(null); + const [volumeVisible, setVolumeVisible] = useState(false); + const [volumeSliderWidth, setVolumeSliderWidth] = useState(0); + const [localVolume, setLocalVolume] = useState(props.volume); + const sliderWidth = volumeVisible ? volumeSliderWidth : 0; + const sliderOpacity = volumeVisible ? 1 : 0; + + const volumeSliderRef = useRef(null); + + const fadeAnim = useRef(new Animated.Value(sliderOpacity)).current; + const widthAnim = useRef(new Animated.Value(sliderWidth)).current; useEffect(() => { - if (hovered && showControls) { - Animated.timing(fadeAnim, { - toValue: 1, - duration: 150, - useNativeDriver: true, - easing: Easing.out(Easing.cubic), - }).start(); - } else { + Animated.parallel([ Animated.timing(fadeAnim, { - toValue: 0, - delay: 150, - duration: 150, - useNativeDriver: true, - easing: Easing.out(Easing.cubic), - }).start(); - } - }, [hovered, showControls, fadeAnim]); + toValue: sliderOpacity, + duration: 175, + useNativeDriver: false, + }), + Animated.timing(widthAnim, { + toValue: sliderWidth, + duration: 175, + useNativeDriver: false, + }), + ]).start(); + }, [fadeAnim, widthAnim, sliderOpacity, sliderWidth]); - // Update slider value when volume or mute changes useEffect(() => { - if (muted) { - setSliderValue(0); - } else { - setSliderValue(volume); + if (volumeSliderRef.current) { + const rect = volumeSliderRef.current.getBoundingClientRect(); + setVolumeSliderWidth(rect.width); } - }, [volume, muted]); + }, [volumeSliderRef]); - // Remember last non-zero volume useEffect(() => { - if (!muted && volume > 0) { - lastVolumeRef.current = volume; - } - }, [volume, muted]); - - // Handle volume slider value change - const handleValueChange = (value) => { - const newValue = value[0]; - setSliderValue(newValue); - if (newValue === 0) { - setMuted(true); - } else { - setVolume(newValue); - if (muted) { - setMuted(false); - } - } - }; - - // Handle icon click to toggle mute/unmute - const handleIconClick = (e) => { - e.stopPropagation(); - if (muted) { - setMuted(false); - setVolume(lastVolumeRef.current || 1); - } else { - setMuted(true); - } - }; + setLocalVolume(props.volume); + }, [props.volume]); - const handleMouseEnter = () => { - if (hideTimer.current) { - clearTimeout(hideTimer.current); - hideTimer.current = null; - } - setHovered(true); - }; - - const handleMouseLeave = () => { - hideTimer.current = setTimeout(() => setHovered(false), 300); - }; + const handleVolumeChange = useCallback( + (volume: number[]) => { + const newVolume = volume[0]; + setLocalVolume(newVolume); + props.setVolume(newVolume); + }, + [props.setVolume], + ); - if (!isWeb) { - return ( - - - {muted ? : } - - - ); - } + const handleMuteToggle = useCallback(() => { + props.setMuted(!props.muted); + }, [props.muted, props.setMuted]); return ( - setVolumeVisible(true)} + onPointerLeave={() => setVolumeVisible(false)} + height={50} + paddingRight="$3" > - - - {muted ? : } - - - - + {props.muted ? : } + + + {Platform.OS === "web" && ( + - - - - - - - + + + + + + + + + + )} + ); }; -function isRefObject(ref: any): ref is { current: HTMLVideoElement | null } { +function isRefObject( + ref: any, +): ref is + | React.RefObject + | React.MutableRefObject { return ref && typeof ref === "object" && "current" in ref; } -export default function Controls( - props: PlayerProps & { - videoRef?: - | React.RefObject - | React.MutableRefObject - | ((instance: HTMLVideoElement | null) => void); - }, -) { - const fadeAnim = useRef(new Animated.Value(1)).current; +export default function Controls(props: { name: string; playerId?: string }) { + const playerId = props.playerId; - // useEffect(() => { - // Animated.timing(fadeAnim, { - // toValue: props.showControls ? 1 : 1, - // duration: 175, - // useNativeDriver: false, - // }).start(); - // }, [fadeAnim, props.showControls]); + // Get all state from the player store + const muted = usePlayerStore((state) => state.muted, playerId); + const setMuted = usePlayerStore((state) => state.setMuted, playerId); + const volume = usePlayerStore((state) => state.volume, playerId); + const setVolume = usePlayerStore((state) => state.setVolume, playerId); + const fullscreen = usePlayerStore((state) => state.fullscreen, playerId); + const setFullscreen = usePlayerStore( + (state) => state.setFullscreen, + playerId, + ); + const showControls = usePlayerStore((state) => state.showControls, playerId); + const setShowControls = usePlayerStore( + (state) => state.setShowControls, + playerId, + ); + const playTime = usePlayerStore((state) => state.playTime, playerId); + const setPlayTime = usePlayerStore((state) => state.setPlayTime, playerId); + const offline = usePlayerStore((state) => state.offline, playerId); + const muteWasForced = usePlayerStore( + (state) => state.muteWasForced, + playerId, + ); + const setMuteWasForced = usePlayerStore( + (state) => state.setMuteWasForced, + playerId, + ); + const embedded = usePlayerStore((state) => state.embedded, playerId); + const videoRef = usePlayerStore((state) => state.videoRef, playerId); + const pipMode = usePlayerStore((state) => state.pipMode, playerId); + const setPipMode = usePlayerStore((state) => state.setPipMode, playerId); + const setUserInteraction = usePlayerStore( + (state) => state.setUserInteraction, + playerId, + ); + const ingestStarting = usePlayerStore( + (state) => state.ingestStarting, + playerId, + ); + const ingestStreamKey = usePlayerStore( + (state) => state.ingestStreamKey, + playerId, + ); + + const fadeAnim = useRef(new Animated.Value(1)).current; let cursor = {}; - if (props.fullscreen && !props.showControls) { + if (fullscreen && !showControls) { cursor = { cursor: "none" }; } const onPress = () => { - props.userInteraction(); - props.setPlayTime(Date.now()); + setUserInteraction(); + setPlayTime(Date.now()); }; const viewers = useViewers(); @@ -286,8 +267,8 @@ export default function Controls( useEffect(() => { let video: HTMLVideoElement | null = null; - if (isRefObject(props.videoRef)) { - video = props.videoRef.current; + if (isRefObject(videoRef)) { + video = videoRef.current; } if (video) { setPipSupported( @@ -297,12 +278,12 @@ export default function Controls( } else { setPipSupported(false); } - }, [props.videoRef, props.ingest]); + }, [videoRef, ingestStreamKey]); useEffect(() => { let video: HTMLVideoElement | null = null; - if (isRefObject(props.videoRef)) { - video = props.videoRef.current; + if (isRefObject(videoRef)) { + video = videoRef.current; } if (!video) return; function onEnter() { @@ -315,23 +296,26 @@ export default function Controls( video.addEventListener("leavepictureinpicture", onLeave); return () => { if (video) { - // ts is mad if we don't check this video.removeEventListener("enterpictureinpicture", onEnter); video.removeEventListener("leavepictureinpicture", onLeave); } }; - }, [props.videoRef]); + }, [videoRef]); const handlePip = useCallback(() => { let video: HTMLVideoElement | null = null; - if (isRefObject(props.videoRef)) { - video = props.videoRef.current; + if (isRefObject(videoRef)) { + video = videoRef.current; } if (!video) return; video.requestPictureInPicture().catch((err) => { console.error("Failed to enter Picture-in-Picture mode", err); }); - }, [props.videoRef]); + }, [videoRef]); + + const userInteraction = () => { + setUserInteraction(); + }; return ( - {props.muteWasForced && ( + {muteWasForced && ( )} - {!props.offline ? null : ( + {!offline ? null : ( - + + + )} - {/* */} { - if (props.embedded) { + if (embedded) { // Open the current URL in a new window const u = new URL(window.location.href); u.pathname = u.pathname.replace("/embed", ""); window.open(u.toString(), "_blank"); - props.setMuted(true); + setMuted(true); } }} > @@ -404,7 +378,7 @@ export default function Controls( - {props.embedded && m.gtXs ? ( + {embedded && m.gtXs ? ( <> - {props.ingest && } - + {ingestStreamKey ? : null} + { - props.setVolume(vol); - props.setMuteWasForced(false); + setVolume(vol); + setMuteWasForced(false); }} - muted={props.muted} - showControls={props.showControls} + muted={muted} + showControls={showControls} setMuted={(muted) => { dispatch(userMute(muted)); - props.setMuteWasForced(false); - props.setMuted(muted); + setMuteWasForced(false); + setMuted(muted); }} /> - + {pipSupported && ( props.setFullscreen(!props.fullscreen)} + onPress={() => setFullscreen(!fullscreen)} > - {props.fullscreen ? : } + {fullscreen ? : } - {/* */} ); } -export function PopoverMenu(props: PlayerProps) { +export function PopoverMenu(props: { playerId?: string }) { + const playerId = props.playerId; const [open, setOpen] = useState(false); const media = useMedia(); const renditions = useRenditions(); - const selectedRendition = usePlayerStore((x) => x.selectedRendition); - const setSelectedRendition = usePlayerStore((x) => x.setSelectedRendition); - const protocol = usePlayerStore((x) => x.protocol); - const setProtocol = usePlayerStore((x) => x.setProtocol); + const selectedRendition = usePlayerStore( + (x) => x.selectedRendition, + playerId, + ); + const setSelectedRendition = usePlayerStore( + (x) => x.setSelectedRendition, + playerId, + ); + const protocol = usePlayerStore((x) => x.protocol, playerId); + const setProtocol = usePlayerStore((x) => x.setProtocol, playerId); + const showControls = usePlayerStore((x) => x.showControls, playerId); const dispatch = useAppDispatch(); - // on android, this appears to lose its context. idk. so we just pass everything through. + const gearMenu = ( ); + useEffect(() => { - if (!media.sm && props.showControls === false) { + if (!media.sm && showControls === false) { setOpen(false); } - }, [props.showControls, media.sm]); + }, [showControls, media.sm]); + return ( x.ingestStarting); - const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); +function LiveBubble(props: { playerId?: string }) { + const playerId = props.playerId; + const ingestStarting = usePlayerStore((x) => x.ingestStarting, playerId); + const setIngestStarting = usePlayerStore( + (x) => x.setIngestStarting, + playerId, + ); + return ( - + ); } -function LiveBubbleText() { - const ingestStarting = usePlayerStore((x) => x.ingestStarting); - const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState); +function LiveBubbleText(props: { playerId?: string }) { + const playerId = props.playerId; + const ingestStarting = usePlayerStore((x) => x.ingestStarting, playerId); + const ingestConnectionState = usePlayerStore( + (x) => x.ingestConnectionState, + playerId, + ); + if (!ingestStarting) { return

START STREAMING

; } @@ -625,16 +618,15 @@ function LiveBubbleText() { return ; } -function GearMenu( - props: PlayerProps & { - renditions: PlaceStreamDefs.Rendition[]; - selectedRendition: string; - protocol: string; - setSelectedRendition: (rendition: string) => void; - setProtocol: (protocol: string) => void; - dispatch: Dispatch; - }, -) { +function GearMenu(props: { + playerId?: string; + renditions: PlaceStreamDefs.Rendition[]; + selectedRendition: string; + protocol: string; + setSelectedRendition: (rendition: string) => void; + setProtocol: (protocol: string) => void; + dispatch: Dispatch; +}) { const [menu, setMenu] = useState("root"); const { renditions, @@ -694,38 +686,10 @@ function GearMenu( subTitle="HTTP Live Streaming" icon={Star} iconAfter={protocol === PROTOCOL_HLS ? CheckCircle : Circle} - onPress={() => dispatch(setProtocol(PROTOCOL_HLS))} + onPress={() => setProtocol(PROTOCOL_HLS)} /> - {/* - dispatch(setProtocol(PROTOCOL_PROGRESSIVE_MP4))} - /> - - - - dispatch(setProtocol(PROTOCOL_PROGRESSIVE_WEBM))} - /> - - */} setSelectedRendition("auto")} /> @@ -776,9 +740,7 @@ function GearMenu( title="Source" subTitle="Original quality" icon={Star} - iconAfter={ - props.selectedRendition === "source" ? CheckCircle : Circle - } + iconAfter={selectedRendition === "source" ? CheckCircle : Circle} onPress={() => setSelectedRendition("source")} /> diff --git a/js/app/components/player/fullscreen.native.tsx b/js/app/components/player/fullscreen.native.tsx index 4ed6209f8..cac233e19 100644 --- a/js/app/components/player/fullscreen.native.tsx +++ b/js/app/components/player/fullscreen.native.tsx @@ -1,5 +1,5 @@ import { useNavigation } from "@react-navigation/native"; -import { PlayerProtocol, usePlayerProtocol } from "@streamplace/components"; +import { PlayerProtocol, usePlayerStore } from "@streamplace/components"; import { VideoView } from "expo-video"; import { useEffect, useRef, useState } from "react"; import { BackHandler, Dimensions, StatusBar, StyleSheet } from "react-native"; @@ -7,19 +7,31 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { View } from "tamagui"; import Controls from "./controls"; import PlayerLoading from "./player-loading"; -import { PlayerProps } from "./props"; import VideoRetry from "./video-retry"; import Video from "./video.native"; // Standard 16:9 video aspect ratio const VIDEO_ASPECT_RATIO = 16 / 9; -export default function Fullscreen(props: PlayerProps) { +export default function Fullscreen(props: { + name: string; + src: string; + playerId?: string; + telemetry?: boolean; + avSyncTest?: boolean; + forceProtocol?: string; +}) { const ref = useRef(null); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const [dimensions, setDimensions] = useState(Dimensions.get("window")); - const protocol = usePlayerProtocol()[0]; + + const playerId = props.playerId; + + // Get state from player store + const protocol = usePlayerStore((x) => x.protocol); + const fullscreen = usePlayerStore((x) => x.fullscreen); + const setFullscreen = usePlayerStore((x) => x.setFullscreen); // Re-calculate dimensions on orientation change useEffect(() => { @@ -39,7 +51,7 @@ export default function Fullscreen(props: PlayerProps) { // Hide status bar when in fullscreen mode useEffect(() => { - if (props.fullscreen) { + if (fullscreen) { StatusBar.setHidden(true); console.log("setting sidebar hidden"); @@ -52,7 +64,7 @@ export default function Fullscreen(props: PlayerProps) { const backHandler = BackHandler.addEventListener( "hardwareBackPress", () => { - props.setFullscreen(false); + setFullscreen(false); return true; }, ); @@ -76,31 +88,32 @@ export default function Fullscreen(props: PlayerProps) { headerShown: true, }); }; - }, [props.fullscreen, navigation]); + }, [fullscreen, navigation, setFullscreen]); - const setFullscreen = (on: boolean) => { - // For WebRTC, use custom fullscreen implementation + // Handle fullscreen state changes for native video players + useEffect(() => { + // For WebRTC, we handle fullscreen manually via the custom implementation if (protocol === PlayerProtocol.PLAYER_PROTOCOL_WEBRTC) { - props.setFullscreen(on); return; } - // For HLS and other protocols, use native fullscreen + // For HLS and other protocols, sync with native fullscreen if (ref.current) { - if (on) { + if (fullscreen) { ref.current.enterFullscreen(); } else { ref.current.exitFullscreen(); } } - }; + }, [fullscreen, protocol]); - if (props.fullscreen && protocol === PlayerProtocol.PLAYER_PROTOCOL_WEBRTC) { + if (fullscreen && protocol === PlayerProtocol.PLAYER_PROTOCOL_WEBRTC) { // Determine if we're in landscape mode const isLandscape = dimensions.width > dimensions.height; // Calculate video container dimensions based on screen size and orientation - let videoWidth, videoHeight; + let videoWidth: number; + let videoHeight: number; if (isLandscape) { // In landscape, account for safe areas and use available height @@ -148,11 +161,24 @@ export default function Fullscreen(props: PlayerProps) { }, ]} > - -