From bec9dfe2fb18c1abd37a06140ad3d230850218ff Mon Sep 17 00:00:00 2001 From: natalie <22222885+espeon@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:02:53 -0500 Subject: [PATCH] player: implement new responsive player (#386) * Start work on responsive player * Add DesktopUi component for desktop livestream layout * redo styling exports, add quick reference (finally) * Add desktop chat UI with hover actions and rename desktopui component - get rid of unneeded/obvious comments * Improve safe area handling and responsive layout on mobile * Refactor useResponsiveLayout to simplify config and return values * Move desktop chat panel up for keyboard * Remove top and bottom safe area padding from Player * Add volume slider and fullscreen toggle to desktop UI * remove unneeded FullscreenToggle * allow passing ui down so we can get proper fullscreen * replace touch-to-show-ui with hover-to-show-ui * add volume change, muteWasForced * animate volume slider on volume icon hover * Add start for emoji stuff * Move touchable without feedback to more targeted spot * Add emoji picker, buttons for emoji and autocomplete to chat box * refresh lockfile * finish previous merge * Add emoji-mart dep to components * run make fix ig * promote New Player to main player * Randomize emoji icon on hover out of selector button * remove extraneous logging * move hover to the right spot * Clean up mutes * Add system message support for chat and livestream events * add proper ""did"" for system user Co-authored-by: Eli Mallon * re-add offline counter, video retry * remove emoji/@ buttons on non-web + switch @ to Lucide * Migrate most uses of the old player to New Player * show simple login prompt if agent is not logged in * Fix embed and multi screens * get rid of duplicate offlinecounters * show basic debug stats when requested * improve @ mention button to update suggestions and focus input * re-add hover in to change cool emoji list index * tests work now * add link + action icon for log in chat button * put mute layer below everything else * re-add offline counter * re-add offline counter * Add video retry to components * pip button * clean up design + add PiP to new player * fix system message, nick color in temp message * Start work on responsive player * Add desktop chat UI with hover actions and rename desktopui component - get rid of unneeded/obvious comments * Add emoji picker, buttons for emoji and autocomplete to chat box * finish previous merge * re-add offline counter * Add video retry to components * share sheet component with Bluesky sharing and copy options * fix layout * create embedding docs * fix dropdown layout issues * add rn-svg * remove place.stream.account.* from docs? maybe that'll fix lexicon issues * fix layout issue wrt merge * apply (?) #388 (reloading fix) * break down desktop ui into components + add loading overlay * move keep awake to components lib * remove styling system progress --------- Co-authored-by: Eli Mallon --- js/app/components/keep-awake.tsx | 3 - js/app/components/mobile/bottom-metadata.tsx | 95 ++ js/app/components/mobile/chat.tsx | 138 +- js/app/components/mobile/desktop-ui.tsx | 304 ++++ .../mobile/desktop-ui/bottom-controls.tsx | 58 + js/app/components/mobile/desktop-ui/index.ts | 5 + .../mobile/desktop-ui/live-bubble.tsx | 35 + .../mobile/desktop-ui/mute-overlay.tsx | 74 + .../mobile/desktop-ui/top-controls.tsx | 107 ++ .../mobile/desktop-ui/volume-slider.tsx | 109 ++ .../mobile/offline-counter/index.tsx | 114 ++ js/app/components/mobile/player.tsx | 144 +- js/app/components/mobile/ui.tsx | 367 +++-- .../components/mobile/useResponsiveLayout.ts | 77 + js/app/components/player/player-loading.tsx | 7 +- js/app/src/router.tsx | 10 +- js/app/src/screens/embed.tsx | 22 +- js/app/src/screens/mobile-go-live.tsx | 7 +- js/app/src/screens/mobile-stream.tsx | 7 +- js/app/src/screens/multi.tsx | 66 +- js/components/package.json | 4 + .../src/components/chat/chat-box.tsx | 300 +++- .../src/components/chat/chat-message.tsx | 13 +- js/components/src/components/chat/chat.tsx | 185 ++- .../src/components/chat/emoji-suggestions.tsx | 94 ++ .../src/components/chat/system-message.tsx | 38 + .../src/components/icons/bluesky-icon.tsx | 9 + .../src}/components/keep-awake.native.tsx | 2 +- js/components/src/components/keep-awake.tsx | 3 + .../mobile-player/fullscreen.native.tsx | 4 +- .../components/mobile-player/fullscreen.tsx | 13 +- .../src/components/mobile-player/player.tsx | 16 +- .../src/components/mobile-player/ui/index.ts | 3 +- ...ading.tsx => streamer-loading-overlay.tsx} | 2 +- .../mobile-player/ui/viewer-context-menu.tsx | 6 +- .../ui/viewer-loading-overlay.tsx | 66 + .../components/mobile-player/video-retry.tsx | 36 + .../src/components/mobile-player/video.tsx | 118 +- .../src/components/share/sharesheet.tsx | 185 +++ js/components/src/components/ui/dropdown.tsx | 18 +- js/components/src/components/ui/index.ts | 1 + js/components/src/components/ui/slider.tsx | 1 + js/components/src/hooks/index.ts | 1 + js/components/src/hooks/usePointerDevice.ts | 89 ++ js/components/src/index.tsx | 13 +- js/components/src/lib/system-messages.ts | 135 ++ js/components/src/livestream-store/chat.tsx | 30 +- .../livestream-store/websocket-consumer.tsx | 17 +- js/components/src/ui/index.ts | 86 ++ .../styling-quick-reference.md | 530 +++++++ .../start-contributing/styling-system.md | 1360 +++++++++++++++++ .../docs/guides/start-streaming/embed.md | 27 + .../account/place-stream-account-defs.md | 43 - .../account/place-stream-account-login.md | 74 - pnpm-lock.yaml | 85 ++ 55 files changed, 4881 insertions(+), 475 deletions(-) delete mode 100644 js/app/components/keep-awake.tsx create mode 100644 js/app/components/mobile/bottom-metadata.tsx create mode 100644 js/app/components/mobile/desktop-ui.tsx create mode 100644 js/app/components/mobile/desktop-ui/bottom-controls.tsx create mode 100644 js/app/components/mobile/desktop-ui/index.ts create mode 100644 js/app/components/mobile/desktop-ui/live-bubble.tsx create mode 100644 js/app/components/mobile/desktop-ui/mute-overlay.tsx create mode 100644 js/app/components/mobile/desktop-ui/top-controls.tsx create mode 100644 js/app/components/mobile/desktop-ui/volume-slider.tsx create mode 100644 js/app/components/mobile/offline-counter/index.tsx create mode 100644 js/app/components/mobile/useResponsiveLayout.ts create mode 100644 js/components/src/components/chat/emoji-suggestions.tsx create mode 100644 js/components/src/components/chat/system-message.tsx create mode 100644 js/components/src/components/icons/bluesky-icon.tsx rename js/{app => components/src}/components/keep-awake.native.tsx (87%) create mode 100644 js/components/src/components/keep-awake.tsx rename js/components/src/components/mobile-player/ui/{loading.tsx => streamer-loading-overlay.tsx} (99%) create mode 100644 js/components/src/components/mobile-player/ui/viewer-loading-overlay.tsx create mode 100644 js/components/src/components/mobile-player/video-retry.tsx create mode 100644 js/components/src/components/share/sharesheet.tsx create mode 100644 js/components/src/components/ui/slider.tsx create mode 100644 js/components/src/hooks/usePointerDevice.ts create mode 100644 js/components/src/lib/system-messages.ts create mode 100644 js/components/src/ui/index.ts create mode 100644 js/docs/src/content/docs/guides/start-contributing/styling-quick-reference.md create mode 100644 js/docs/src/content/docs/guides/start-contributing/styling-system.md create mode 100644 js/docs/src/content/docs/guides/start-streaming/embed.md delete mode 100644 js/docs/src/content/docs/lex-reference/account/place-stream-account-defs.md delete mode 100644 js/docs/src/content/docs/lex-reference/account/place-stream-account-login.md diff --git a/js/app/components/keep-awake.tsx b/js/app/components/keep-awake.tsx deleted file mode 100644 index 41133f6a..00000000 --- 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 00000000..2367934a --- /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 b469725c..7b2a79e8 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 00000000..23a526cc --- /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 00000000..e4613f09 --- /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 00000000..c156af42 --- /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 00000000..dec9b4d4 --- /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 00000000..7f3a1f5e --- /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 00000000..d62c79ad --- /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 00000000..23b72e97 --- /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 00000000..2ff6d42d --- /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 0661fcc0..9da00f14 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(