diff --git a/js/app/components/keep-awake.tsx b/js/app/components/keep-awake.tsx deleted file mode 100644 index 41133f6ab..000000000 --- a/js/app/components/keep-awake.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function KeepAwake() { - return <>; -} diff --git a/js/app/components/mobile/bottom-metadata.tsx b/js/app/components/mobile/bottom-metadata.tsx new file mode 100644 index 000000000..2367934a2 --- /dev/null +++ b/js/app/components/mobile/bottom-metadata.tsx @@ -0,0 +1,95 @@ +import { + Button, + layout, + PlayerUI, + ShareSheet, + Text, + useAvatars, + useLivestreamInfo, + useLivestreamStore, + zero, +} from "@streamplace/components"; +import { ChevronLeft } from "@tamagui/lucide-icons"; +import { ChevronRight } from "lucide-react-native"; +import { Image, View } from "react-native"; +const { gap, px, py, colors } = zero; + +export function BottomMetadata({ + setShowChat, + showChat, +}: { + setShowChat: (show: boolean) => void; + showChat: boolean; +}) { + const { profile } = useLivestreamInfo(); + const avatars = useAvatars(profile?.did ? [profile?.did] : []); + const ls = useLivestreamStore((x) => x.livestream); + + return ( + + + {/* Left side - Profile info */} + + {profile?.did && avatars[profile?.did]?.avatar && ( + + )} + {!(profile?.did && avatars[profile?.did]?.avatar) && ( + + )} + + + @{profile?.handle || "user"} + + + {ls?.record.title || "Stream Title"} + + + + + {/* Right side - Viewer count and collapse chat */} + + + + + + + + ); +} diff --git a/js/app/components/mobile/chat.tsx b/js/app/components/mobile/chat.tsx index b469725c8..7b2a79e85 100644 --- a/js/app/components/mobile/chat.tsx +++ b/js/app/components/mobile/chat.tsx @@ -1,8 +1,108 @@ -import { atoms, Chat, ChatBox, View } from "@streamplace/components"; -import { flex, px, py } from "@streamplace/components/src/lib/theme/atoms"; -const { borderRadius, gap, layout } = atoms; +import { + Chat, + ChatBox, + Resizable, + Text, + useHandle, + useLivestreamInfo, + View, + zero, +} from "@streamplace/components"; +import { useKeyboard } from "hooks/useKeyboard"; +import { useEffect } from "react"; +import { Keyboard, Pressable, TouchableWithoutFeedback } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from "react-native-reanimated"; +import { useResponsiveLayout } from "./useResponsiveLayout"; -export function ChatPanel() { +import { useNavigation } from "@react-navigation/native"; +import { ArrowRight } from "@tamagui/lucide-icons"; +import emojiData from "assets/emoji-data.json"; +const { borderRadius, gap, layout, flex, px, py, r, position, bottom } = zero; + +export function DesktopChatPanel({ + chatVisible, + chatPanelWidth, + safeAreaInsets, +}) { + const sidebarOffset = useSharedValue(chatVisible ? 0 : chatPanelWidth); + + const kb = useKeyboard(); + + useEffect(() => { + console.log( + "Setting sidebar offset x to", + chatVisible ? 0 : chatPanelWidth, + ); + sidebarOffset.value = withSpring(chatVisible ? 0 : chatPanelWidth, { + damping: 100, + stiffness: 1000, + }); + }, [chatVisible, chatPanelWidth, sidebarOffset]); + + const animatedSidebarStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: sidebarOffset.value }, + { translateY: -kb.keyboardHeight }, + ], + })); + + return ( + + + + + + ); +} + +// MobileChatPanel.tsx +export function MobileChatPanel({ isPlayerRatioGreater }) { + return ( + + + + + + ); +} + +function ChatPanel() { + const { shouldShowChatSidePanel, safeAreaInsets } = useResponsiveLayout(); + const { profile } = useLivestreamInfo(); + const handle = useHandle(); + const navigation = useNavigation(); + let canModerate = profile?.handle === handle; return ( - - - + + + + + {handle ? ( + + ) : ( + navigation.navigate("Login")} + style={[ + layout.flex.row, + layout.flex.center, + gap.all[2], + { + padding: 16, + borderRadius: borderRadius.xl, + backgroundColor: "rgba(255, 255, 255, 0.1)", + }, + ]} + > + Log in or sign up to chat + + + )} ); diff --git a/js/app/components/mobile/desktop-ui.tsx b/js/app/components/mobile/desktop-ui.tsx new file mode 100644 index 000000000..23a526cc3 --- /dev/null +++ b/js/app/components/mobile/desktop-ui.tsx @@ -0,0 +1,304 @@ +import { + PlayerUI, + Toast, + useLivestreamInfo, + usePlayerDimensions, + usePlayerStore, + useSegment, + View, + zero, +} from "@streamplace/components"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Platform } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { + BottomControlBar, + MuteOverlay, + TopControlBar, +} from "./desktop-ui/index"; +import { useResponsiveLayout } from "./useResponsiveLayout"; + +const { h, layout, position, w, px, py, r, p } = zero; + +function isRefObject( + ref: any, +): ref is + | React.RefObject + | React.MutableRefObject { + return ref && typeof ref === "object" && "current" in ref; +} + +export function DesktopUi() { + const { + ingest, + title, + setTitle, + showCountdown, + setShowCountdown, + recordSubmitted, + setRecordSubmitted, + ingestStarting, + setIngestStarting, + toggleGoLive, + } = useLivestreamInfo(); + const { width, height } = usePlayerDimensions(); + const { safeAreaInsets, shouldShowFloatingMetrics } = useResponsiveLayout(); + + const offline = usePlayerStore((state) => state.offline); + const showMetrics = usePlayerStore((state) => state.showDebugInfo); + const pipAction = usePlayerStore((state) => state.pipAction); + const videoRef = usePlayerStore((state) => state.videoRef); + + const segment = useSegment(); + + const [isControlsVisible, setIsControlsVisible] = useState(true); + const [isChatOpen, setIsChatOpen] = useState(false); + const [pipSupported, setPipSupported] = useState(false); + const [pipActive, setPipActive] = useState(false); + const fadeOpacity = useSharedValue(1); + const fadeTimeout = useRef(null); + const FADE_OUT_DELAY = 500; + + const isSelfAndNotLive = ingest === "new"; + const isActivelyLive = ingest !== null && ingest !== "new"; + + const resetFadeTimer = useCallback(() => { + fadeOpacity.value = withTiming(1, { duration: 200 }); + if (fadeTimeout.current) clearTimeout(fadeTimeout.current); + setIsControlsVisible(true); + + fadeTimeout.current = setTimeout(() => { + fadeOpacity.value = withTiming(0, { duration: 400 }); + setIsControlsVisible(false); + }, FADE_OUT_DELAY); + }, [fadeOpacity]); + + const onPlayerHover = useCallback(() => { + resetFadeTimer(); + }, [resetFadeTimer]); + + const toggleChat = useCallback(() => { + setIsChatOpen((prev) => !prev); + }, []); + + useEffect(() => { + resetFadeTimer(); + + return () => { + if (fadeTimeout.current) clearTimeout(fadeTimeout.current); + if (ingestStarting) { + setIngestStarting(false); + } + }; + }, [ingestStarting, setIngestStarting, resetFadeTimer]); + + const animatedFadeStyle = useAnimatedStyle(() => ({ + opacity: shouldShowFloatingMetrics ? 1 : fadeOpacity.value, + })); + + // Picture-in-Picture support detection + useEffect(() => { + if (Platform.OS === "web") { + setPipSupported( + !!document.pictureInPictureEnabled && pipAction !== undefined, + ); + } + }, [pipAction]); + + // Picture-in-Picture event listeners + useEffect(() => { + if (Platform.OS !== "web") return; + + let video: HTMLVideoElement | null = null; + if (isRefObject(videoRef)) { + video = videoRef.current; + } + if (!video) return; + + function onEnter() { + setPipActive(true); + } + function onLeave() { + setPipActive(false); + } + + video.addEventListener("enterpictureinpicture", onEnter); + video.addEventListener("leavepictureinpicture", onLeave); + + return () => { + if (video) { + video.removeEventListener("enterpictureinpicture", onEnter); + video.removeEventListener("leavepictureinpicture", onLeave); + } + }; + }, [videoRef]); + + const handlePip = useCallback(() => { + if (pipAction) pipAction(); + }, [pipAction]); + + // Live timer for offline overlay + const [timeSinceLastSeen, setTimeSinceLastSeen] = useState("Unknown"); + + useEffect(() => { + if (!offline || !segment?.startTime) { + setTimeSinceLastSeen("Unknown"); + return; + } + + const updateTimer = () => { + const now = new Date(); + const lastSeen = new Date(segment.startTime); + const diffMs = now.getTime() - lastSeen.getTime(); + const diffMinutes = Math.floor(diffMs / 60000); + const diffSeconds = Math.floor((diffMs % 60000) / 1000); + + if (diffMinutes > 0) { + setTimeSinceLastSeen(`${diffMinutes}m ${diffSeconds}s ago`); + } else { + setTimeSinceLastSeen(`${diffSeconds}s ago`); + } + }; + + // Update immediately + updateTimer(); + + // Update every second while offline + const interval = setInterval(updateTimer, 1000); + + return () => clearInterval(interval); + }, [offline, segment?.startTime]); + + const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)()); + + return ( + + <> + + + + + + + + {isActivelyLive && isControlsVisible && ( + + + + + + )} + + + + + + {isSelfAndNotLive && ( + + )} + + { + setShowCountdown(false); + }} + /> + + + + {showMetrics && ( + + + + )} + + + ); +} diff --git a/js/app/components/mobile/desktop-ui/bottom-controls.tsx b/js/app/components/mobile/desktop-ui/bottom-controls.tsx new file mode 100644 index 000000000..e4613f09e --- /dev/null +++ b/js/app/components/mobile/desktop-ui/bottom-controls.tsx @@ -0,0 +1,58 @@ +import { PlayerUI, View, usePlayerStore, zero } from "@streamplace/components"; +import { Fullscreen, Minimize, PictureInPicture2 } from "lucide-react-native"; +import { Platform, Pressable } from "react-native"; +import { VolumeSlider } from "./volume-slider"; + +const { gap, layout, p, r } = zero; + +interface BottomControlBarProps { + ingest: string | null; + pipSupported: boolean; + pipActive: boolean; + onHandlePip: () => void; +} + +export function BottomControlBar({ + ingest, + pipSupported, + pipActive, + onHandlePip, +}: BottomControlBarProps) { + const fullscreen = usePlayerStore((state) => state.fullscreen); + const setFullscreen = usePlayerStore((state) => state.setFullscreen); + + return ( + + + + + + + {Platform.OS === "web" && pipSupported && ( + + + + + + )} + {Platform.OS === "web" && ( + { + setFullscreen(!fullscreen); + }} + style={[p[2], r[1]]} + > + {fullscreen ? : } + + )} + {ingest === null && } + + + ); +} diff --git a/js/app/components/mobile/desktop-ui/index.ts b/js/app/components/mobile/desktop-ui/index.ts new file mode 100644 index 000000000..c156af421 --- /dev/null +++ b/js/app/components/mobile/desktop-ui/index.ts @@ -0,0 +1,5 @@ +export { BottomControlBar } from "./bottom-controls"; +export { LiveBubble } from "./live-bubble"; +export { MuteOverlay } from "./mute-overlay"; +export { TopControlBar } from "./top-controls"; +export { VolumeSlider } from "./volume-slider"; diff --git a/js/app/components/mobile/desktop-ui/live-bubble.tsx b/js/app/components/mobile/desktop-ui/live-bubble.tsx new file mode 100644 index 000000000..dec9b4d4f --- /dev/null +++ b/js/app/components/mobile/desktop-ui/live-bubble.tsx @@ -0,0 +1,35 @@ +import { Code, View, zero } from "@streamplace/components"; + +const { borders, gap, h, w, px, bg, text } = zero; + +export function LiveBubble() { + return ( + + + + + LIVE + + + + ); +} diff --git a/js/app/components/mobile/desktop-ui/mute-overlay.tsx b/js/app/components/mobile/desktop-ui/mute-overlay.tsx new file mode 100644 index 000000000..7f3a1f5e0 --- /dev/null +++ b/js/app/components/mobile/desktop-ui/mute-overlay.tsx @@ -0,0 +1,74 @@ +import { Text, View, usePlayerStore, zero } from "@streamplace/components"; +import { VolumeX } from "lucide-react-native"; +import { Pressable } from "react-native"; + +const { layout, h, w, p, px } = zero; + +export function MuteOverlay() { + const muteWasForced = usePlayerStore((state) => state.muteWasForced); + const setMuted = usePlayerStore((state) => state.setMuted); + const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); + + if (!muteWasForced) return null; + + return ( + + { + if (muteWasForced) { + setMuted(false); + setMuteWasForced(false); + } + }} + style={[ + { + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 8, + }, + ]} + > + + + + + + Press to unmute + + + + + ); +} diff --git a/js/app/components/mobile/desktop-ui/top-controls.tsx b/js/app/components/mobile/desktop-ui/top-controls.tsx new file mode 100644 index 000000000..d62c79ad9 --- /dev/null +++ b/js/app/components/mobile/desktop-ui/top-controls.tsx @@ -0,0 +1,107 @@ +import { useNavigation } from "@react-navigation/native"; +import { + PlayerUI, + Text, + View, + useAvatars, + useCameraToggle, + useLivestreamInfo, + zero, +} from "@streamplace/components"; +import { ChevronLeft, MessageSquare, SwitchCamera } from "lucide-react-native"; +import { Image, Platform, Pressable } from "react-native"; +import { LiveBubble } from "./live-bubble"; + +const { borders, colors, gap, layout, p, r, text } = zero; + +interface TopControlBarProps { + offline: boolean; + isActivelyLive: boolean; + ingest: string | null; + isChatOpen: boolean; + onToggleChat: () => void; + safeAreaInsets: { top: number }; +} + +export function TopControlBar({ + offline, + isActivelyLive, + ingest, + isChatOpen, + onToggleChat, + safeAreaInsets, +}: TopControlBarProps) { + const navigation = useNavigation(); + const { profile } = useLivestreamInfo(); + const { doSetIngestCamera } = useCameraToggle(); + const avatars = useAvatars(profile?.did ? [profile?.did] : []); + + return ( + + + {Platform.OS !== "web" && ( + { + navigation.canGoBack() + ? navigation.goBack() + : navigation.navigate("Home", { screen: "StreamList" }); + }} + style={[p[2], r[1]]} + > + + + )} + + + + + {profile?.handle} + + {!offline && } + + + + + {isActivelyLive && ( + <> + + + + + + + )} + {ingest !== null && ( + + + + )} + + + ); +} diff --git a/js/app/components/mobile/desktop-ui/volume-slider.tsx b/js/app/components/mobile/desktop-ui/volume-slider.tsx new file mode 100644 index 000000000..23b72e971 --- /dev/null +++ b/js/app/components/mobile/desktop-ui/volume-slider.tsx @@ -0,0 +1,109 @@ +import { Slider, View, usePlayerStore, zero } from "@streamplace/components"; +import { Volume2, VolumeX } from "lucide-react-native"; +import { useCallback } from "react"; +import { Pressable } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +const { layout, p, r } = zero; + +export function VolumeSlider() { + const muted = usePlayerStore((state) => state.muted); + const setMuted = usePlayerStore((state) => state.setMuted); + const volume = usePlayerStore((state) => state.volume); + const setVolume = usePlayerStore((state) => state.setVolume); + + const fadeAnim = useSharedValue(0); + const widthAnim = useSharedValue(0); + + const onVolumeHover = useCallback(() => { + fadeAnim.value = withTiming(1, { duration: 200 }); + widthAnim.value = withTiming(200, { duration: 200 }); + }, [fadeAnim, widthAnim]); + + // Toggle mute state + const handleMuteToggle = useCallback(() => { + setMuted(!muted); + }, [muted, setMuted]); + + const VolumeIcon = muted ? VolumeX : Volume2; + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: fadeAnim.value, + width: widthAnim.value, + })); + + // Convert volume (0-1) to percentage (0-100) for slider + const sliderValue = (muted ? 0 : volume) * 100; + return ( + + + + + + + { + const newVolume = vals[0] / 100; // Convert back to 0-1 range + setVolume(newVolume); + if (newVolume === 0) { + setMuted(true); + } else { + setMuted(false); + } + }} + asChild + > + + + + + + + + ); +} diff --git a/js/app/components/mobile/offline-counter/index.tsx b/js/app/components/mobile/offline-counter/index.tsx new file mode 100644 index 000000000..2ff6d42d0 --- /dev/null +++ b/js/app/components/mobile/offline-counter/index.tsx @@ -0,0 +1,114 @@ +import { + Text, + usePlayerStore, + useSegment, + View, + zero, +} from "@streamplace/components"; +import { useEffect, useState } from "react"; + +const { gap, h, layout, position, w } = zero; + +interface OfflineCounterProps { + isMobile?: boolean; +} + +export function OfflineCounter({ isMobile = false }: OfflineCounterProps) { + const offline = usePlayerStore((state) => state.offline); + const segment = useSegment(); + + // Live timer for offline overlay + const [timeSinceLastSeen, setTimeSinceLastSeen] = useState("Unknown"); + + useEffect(() => { + if (!offline || !segment?.startTime) { + setTimeSinceLastSeen("Unknown"); + return; + } + + const updateTimer = () => { + const now = new Date(); + const lastSeen = new Date(segment.startTime); + const diffMs = now.getTime() - lastSeen.getTime(); + const diffMinutes = Math.floor(diffMs / 60000); + const diffSeconds = Math.floor((diffMs % 60000) / 1000); + + if (diffMinutes > 0) { + setTimeSinceLastSeen(`${diffMinutes}m ${diffSeconds}s ago`); + } else { + setTimeSinceLastSeen(`${diffSeconds}s ago`); + } + }; + + // Update immediately + updateTimer(); + + // Update every second while offline + const interval = setInterval(updateTimer, 1000); + + return () => clearInterval(interval); + }, [offline, segment?.startTime]); + + if (!offline) return null; + + const titleFontSize = isMobile ? 24 : 32; + const subtitleFontSize = isMobile ? 16 : 18; + const descriptionFontSize = isMobile ? 14 : 16; + const descriptionMarginTop = isMobile ? 8 : 12; + const maxWidth = isMobile ? undefined : 400; + + return ( + + + + Stream Offline + + + Last seen: {timeSinceLastSeen} + + + The stream will resume automatically when the broadcaster returns + + + + ); +} diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx index 0661fcc04..9da00f142 100644 --- a/js/app/components/mobile/player.tsx +++ b/js/app/components/mobile/player.tsx @@ -3,25 +3,39 @@ import { Button, layout, LivestreamProvider, - Player as PlayerInner, + Player as PlayerInnerInner, PlayerProps, PlayerProvider, Text, + usePlayerDimensions, + usePlayerStore, View, } from "@streamplace/components"; import { gap, h, pt, w } from "@streamplace/components/src/lib/theme/atoms"; import { ArrowLeft, ArrowRight } from "@tamagui/lucide-icons"; import { selectUserProfile } from "features/bluesky/blueskySlice"; import { useLiveUser } from "hooks/useLiveUser"; +import { useSidebarControl } from "hooks/useSidebarControl"; import { useEffect, useState } from "react"; +import { Animated, ScrollView } from "react-native"; import { useAppSelector } from "store/hooks"; +import { BottomMetadata } from "./bottom-metadata"; +import { DesktopChatPanel } from "./chat"; +import { DesktopUi } from "./desktop-ui"; +import { OfflineCounter } from "./offline-counter"; import { MobileUi } from "./ui"; +import { useResponsiveLayout } from "./useResponsiveLayout"; export function Player( props: Partial & { setFullscreen?: (fullscreen: boolean) => void; }, ) { + const [showChat, setShowChat] = useState(true); + const { shouldShowChatSidePanel, chatPanelWidth, safeAreaInsets } = + useResponsiveLayout(); + const chatVisible = shouldShowChatSidePanel && showChat; + const [isStreamingElsewhere, setIsStreamingElsewhere] = useState< boolean | null >(null); @@ -78,7 +92,10 @@ export function Player(