From b966eb0c53fdadb1e089ba6ba2feb896ca90e363 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Thu, 4 Jun 2026 19:45:39 -0500 Subject: [PATCH] Add visual play/pause indicator to desktop UI - Introduce `PlayPauseIndicator` component for state transition feedback - Remove manual play button from loading overlay - Refine control bar visibility logic to persist when paused --- js/app/components/mobile/desktop-ui.tsx | 60 +++++++------ js/app/components/mobile/desktop-ui/index.ts | 1 + .../mobile/desktop-ui/mute-overlay.tsx | 1 + .../desktop-ui/play-pause-indicator.tsx | 90 +++++++++++++++++++ .../mobile-player/ui/autoplay-button.tsx | 1 + .../ui/viewer-loading-overlay.tsx | 79 +++++----------- 6 files changed, 147 insertions(+), 85 deletions(-) create mode 100644 js/app/components/mobile/desktop-ui/play-pause-indicator.tsx diff --git a/js/app/components/mobile/desktop-ui.tsx b/js/app/components/mobile/desktop-ui.tsx index 7050fbba..37dbf2ef 100644 --- a/js/app/components/mobile/desktop-ui.tsx +++ b/js/app/components/mobile/desktop-ui.tsx @@ -1,4 +1,5 @@ import { + PlayerStatus, PlayerUI, PortalHost, Toast, @@ -26,6 +27,7 @@ import { MuteOverlay, TopControlBar, } from "./desktop-ui/index"; +import { PlayPauseIndicator } from "./desktop-ui/play-pause-indicator"; import { useResponsiveLayout } from "./useResponsiveLayout"; const { h, layout, position, w, px, py, r, p } = zero; @@ -72,6 +74,7 @@ export function DesktopUi({ const fullscreen = usePlayerStore((state) => state.fullscreen); const setFullscreen = usePlayerStore((state) => state.setFullscreen); const selectedRendition = usePlayerStore((state) => state.selectedRendition); + const status = usePlayerStore((state) => state.status); const safeAreaInsets = embedded ? { ...originalSafeAreaInsets, top: 0 } @@ -96,12 +99,13 @@ export function DesktopUi({ if (selectedRendition === "audio") return; if (ingest !== null) return; + if (status === PlayerStatus.PAUSE) return; fadeTimeout.current = setTimeout(() => { fadeOpacity.value = withTiming(0, { duration: 400 }); setIsControlsVisible(false); }, FADE_OUT_DELAY); - }, [fadeOpacity, selectedRendition, ingest]); + }, [fadeOpacity, selectedRendition, ingest, status]); const onPlayerHover = useCallback(() => { resetFadeTimer(); @@ -225,8 +229,8 @@ export function DesktopUi({ collapsable={false} > - + )} - - - - - - {isSelfAndNotLive && ( + + + + + {fullscreen && } ); diff --git a/js/app/components/mobile/desktop-ui/index.ts b/js/app/components/mobile/desktop-ui/index.ts index 91c372da..3b8f0ba8 100644 --- a/js/app/components/mobile/desktop-ui/index.ts +++ b/js/app/components/mobile/desktop-ui/index.ts @@ -2,5 +2,6 @@ export { BottomControlBar } from "./bottom-controls"; export { KebabMenu } from "./kebab"; export { LiveBubble } from "./live-bubble"; export { MuteOverlay } from "./mute-overlay"; +export { PlayPauseIndicator } from "./play-pause-indicator"; export { TopControlBar } from "./top-controls"; export { VolumeSlider } from "./volume-slider"; diff --git a/js/app/components/mobile/desktop-ui/mute-overlay.tsx b/js/app/components/mobile/desktop-ui/mute-overlay.tsx index 09ed23ff..565647da 100644 --- a/js/app/components/mobile/desktop-ui/mute-overlay.tsx +++ b/js/app/components/mobile/desktop-ui/mute-overlay.tsx @@ -35,6 +35,7 @@ export function MuteOverlay() { h.percent[100], w.percent[100], ]} + pointerEvents="box-none" > { diff --git a/js/app/components/mobile/desktop-ui/play-pause-indicator.tsx b/js/app/components/mobile/desktop-ui/play-pause-indicator.tsx new file mode 100644 index 00000000..1827414d --- /dev/null +++ b/js/app/components/mobile/desktop-ui/play-pause-indicator.tsx @@ -0,0 +1,90 @@ +import { PlayerStatus, usePlayerStore } from "@streamplace/components"; +import { Pause, Play } from "lucide-react-native"; +import { useCallback, useEffect, useRef } from "react"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSequence, + withTiming, +} from "react-native-reanimated"; + +export function PlayPauseIndicator() { + const status = usePlayerStore((x) => x.status); + const prevStatus = useRef(status); + const opacity = useSharedValue(0); + const scale = useSharedValue(0.5); + + const animate = useCallback( + (toPlaying: boolean) => { + opacity.value = 1; + opacity.value = withSequence( + withTiming(1, { duration: 100 }), + withTiming(1, { duration: 400 }), + withTiming(0, { duration: 300 }), + ); + if (toPlaying) { + scale.value = 0.8; + scale.value = withSequence( + withTiming(1, { duration: 100 }), + withTiming(1, { duration: 400 }), + withTiming(1.3, { duration: 300 }), + ); + } else { + scale.value = 1; + scale.value = withSequence( + withTiming(0.8, { duration: 100 }), + withTiming(0.8, { duration: 500 }), + ); + } + }, + [opacity, scale], + ); + + useEffect(() => { + if (status !== prevStatus.current) { + const wasPlaying = prevStatus.current === PlayerStatus.PLAYING; + const isPlaying = status === PlayerStatus.PLAYING; + const isPaused = status === PlayerStatus.PAUSE; + + if (isPlaying || isPaused) { + animate(isPlaying); + } + prevStatus.current = status; + } + }, [status, animate]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ scale: scale.value }], + })); + + const isPlaying = status === PlayerStatus.PLAYING; + + return ( + + {isPlaying ? ( + + ) : ( + + )} + + ); +} diff --git a/js/components/src/components/mobile-player/ui/autoplay-button.tsx b/js/components/src/components/mobile-player/ui/autoplay-button.tsx index 069558d3..8636e930 100644 --- a/js/components/src/components/mobile-player/ui/autoplay-button.tsx +++ b/js/components/src/components/mobile-player/ui/autoplay-button.tsx @@ -50,6 +50,7 @@ export function AutoplayButton() { h.percent[100], w.percent[100], ]} + pointerEvents="box-none" > x.status); - const togglePlayPause = usePlayerStore((x) => x.togglePlayPause); - const { theme, zero: zt } = useTheme(); const opacity = useSharedValue(0); useEffect(() => { @@ -28,60 +18,33 @@ export function ViewerLoadingOverlay() { } }, [status, opacity]); - const animatedStyle = useAnimatedStyle(() => { - return { - opacity: opacity.value, - }; - }); + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); if (status === PlayerStatus.PLAYING) { return ; } - if (status === PlayerStatus.SUSPEND) { - return null; // No overlay when stopped + if (status === PlayerStatus.SUSPEND || status === PlayerStatus.PAUSE) { + return null; } - const isPaused = status === PlayerStatus.PAUSE; - return ( - <> - - {!isPaused && } - - {isPaused && ( - - - - )} - + + + ); } -- 2.51.2