diff --git a/js/app/components/mobile/desktop-ui.tsx b/js/app/components/mobile/desktop-ui.tsx
index 23a526cc..2da27e0d 100644
--- a/js/app/components/mobile/desktop-ui.tsx
+++ b/js/app/components/mobile/desktop-ui.tsx
@@ -2,6 +2,7 @@ import {
PlayerUI,
Toast,
useLivestreamInfo,
+ useOffline,
usePlayerDimensions,
usePlayerStore,
useSegment,
@@ -50,7 +51,7 @@ export function DesktopUi() {
const { width, height } = usePlayerDimensions();
const { safeAreaInsets, shouldShowFloatingMetrics } = useResponsiveLayout();
- const offline = usePlayerStore((state) => state.offline);
+ const offline = useOffline();
const showMetrics = usePlayerStore((state) => state.showDebugInfo);
const pipAction = usePlayerStore((state) => state.pipAction);
const videoRef = usePlayerStore((state) => state.videoRef);
diff --git a/js/app/components/mobile/offline-counter/index.tsx b/js/app/components/mobile/offline-counter/index.tsx
index 2ff6d42d..e3a68726 100644
--- a/js/app/components/mobile/offline-counter/index.tsx
+++ b/js/app/components/mobile/offline-counter/index.tsx
@@ -1,6 +1,6 @@
import {
Text,
- usePlayerStore,
+ useOffline,
useSegment,
View,
zero,
@@ -14,7 +14,7 @@ interface OfflineCounterProps {
}
export function OfflineCounter({ isMobile = false }: OfflineCounterProps) {
- const offline = usePlayerStore((state) => state.offline);
+ const offline = useOffline();
const segment = useSegment();
// Live timer for offline overlay
diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx
index 9da00f14..146f1ec8 100644
--- a/js/app/components/mobile/player.tsx
+++ b/js/app/components/mobile/player.tsx
@@ -6,6 +6,7 @@ import {
Player as PlayerInnerInner,
PlayerProps,
PlayerProvider,
+ PlayerUI,
Text,
usePlayerDimensions,
usePlayerStore,
@@ -227,6 +228,7 @@ export function PlayerInner(
>
{(showBottomMetaPanel || fullscreen) && }
+
diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx
index 643b8f29..d27e7b1d 100644
--- a/js/app/components/mobile/ui.tsx
+++ b/js/app/components/mobile/ui.tsx
@@ -94,7 +94,6 @@ export function MobileUi() {
return (
<>
-
-
state.setMuted, props.playerId);
const showControls = usePlayerStore((state) => state.showControls, playerId);
const setPlayTime = usePlayerStore((state) => state.setPlayTime, playerId);
- const offline = usePlayerStore((state) => state.offline, playerId);
+ const offline = useOffline();
const muteWasForced = usePlayerStore(
(state) => state.muteWasForced,
playerId,
diff --git a/js/app/components/player/player.tsx b/js/app/components/player/player.tsx
index 5a3d139c..3801361c 100644
--- a/js/app/components/player/player.tsx
+++ b/js/app/components/player/player.tsx
@@ -49,9 +49,6 @@ export function PlayerInner(props: Partial) {
// Will get the first player ID from the store
const playerId = getFirstPlayerID();
- const playing = usePlayerStore((x) => x.status === PlayerStatus.PLAYING);
-
- const setOffline = usePlayerStore((x) => x.setOffline);
const setIngest = usePlayerStore((x) => x.setIngestConnectionState);
const clearControlsTimeout = usePlayerStore((x) => x.clearControlsTimeout);
@@ -80,31 +77,6 @@ export function PlayerInner(props: Partial) {
const segment = useSegment();
const [lastCheck, setLastCheck] = useState(0);
- useEffect(() => {
- if (playing) {
- setOffline(false);
- return;
- }
- if (!segment) {
- setOffline(false);
- return;
- }
- const startTime = Date.parse(segment.startTime);
- if (!startTime) {
- console.error("startTime is not a number", segment.startTime);
- return;
- }
- const timeSinceStart = Date.now() - startTime;
- if (timeSinceStart > OFFLINE_THRESHOLD) {
- setOffline(true);
- return;
- }
- const handle = setTimeout(() => {
- setLastCheck(Date.now());
- }, 1000);
- return () => clearTimeout(handle);
- }, [segment, playing, lastCheck]);
-
return (
diff --git a/js/app/components/player/video-retry.tsx b/js/app/components/player/video-retry.tsx
index e5940517..a993760b 100644
--- a/js/app/components/player/video-retry.tsx
+++ b/js/app/components/player/video-retry.tsx
@@ -1,4 +1,4 @@
-import { usePlayerStore } from "@streamplace/components";
+import { useOffline } from "@streamplace/components";
import React, { useEffect, useRef, useState } from "react";
export default function VideoRetry(props: { children: React.ReactNode }) {
@@ -6,7 +6,7 @@ export default function VideoRetry(props: { children: React.ReactNode }) {
const [retries, setRetries] = useState(0);
const [hasStarted, setHasStarted] = useState(false);
- const offline = usePlayerStore((x) => x.offline);
+ const offline = useOffline();
useEffect(() => {
if (!offline && !hasStarted) {
diff --git a/js/components/src/components/mobile-player/fullscreen.native.tsx b/js/components/src/components/mobile-player/fullscreen.native.tsx
index 1bf45381..f525c60c 100644
--- a/js/components/src/components/mobile-player/fullscreen.native.tsx
+++ b/js/components/src/components/mobile-player/fullscreen.native.tsx
@@ -4,7 +4,12 @@ import { useEffect, useRef, useState } from "react";
import { BackHandler, Dimensions, StyleSheet, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { PlayerProtocol, useLivestreamStore, usePlayerStore } from "../..";
+import {
+ PlayerProtocol,
+ useLivestreamStore,
+ usePlayerStore,
+ VideoRetry,
+} from "../..";
import Video from "./video.native";
// Standard 16:9 video aspect ratio
@@ -166,7 +171,9 @@ export function Fullscreen(props: { src: string; children?: React.ReactNode }) {
// Normal non-fullscreen mode
return (
<>
-
+
+
+
{props.children}
>
);
diff --git a/js/components/src/components/mobile-player/player.tsx b/js/components/src/components/mobile-player/player.tsx
index 0b6011e9..887eb7a7 100644
--- a/js/components/src/components/mobile-player/player.tsx
+++ b/js/components/src/components/mobile-player/player.tsx
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import { flex, h, layout, w, zIndex } from "../../lib/theme/atoms";
-import { useSegment } from "../../livestream-store";
import {
PlayerStatus,
PlayerStatusTracker,
@@ -18,9 +17,6 @@ export * as PlayerUI from "./ui";
export function Player(
props: Partial & { children?: React.ReactNode },
) {
- const playing = usePlayerStore((x) => x.status === PlayerStatus.PLAYING);
-
- const setOffline = usePlayerStore((x) => x.setOffline);
const setIngest = usePlayerStore((x) => x.setIngestConnectionState);
const clearControlsTimeout = usePlayerStore((x) => x.clearControlsTimeout);
@@ -52,34 +48,6 @@ export function Player(
};
}, []);
- const segment = useSegment();
- const [lastCheck, setLastCheck] = useState(0);
-
- useEffect(() => {
- if (playing) {
- setOffline(false);
- return;
- }
- if (!segment) {
- setOffline(false);
- return;
- }
- const startTime = Date.parse(segment.startTime);
- if (!startTime) {
- console.error("startTime is not a number", segment.startTime);
- return;
- }
- const timeSinceStart = Date.now() - startTime;
- if (timeSinceStart > OFFLINE_THRESHOLD) {
- setOffline(true);
- return;
- }
- const handle = setTimeout(() => {
- setLastCheck(Date.now());
- }, 1000);
- return () => clearTimeout(handle);
- }, [segment, playing, lastCheck]);
-
return (
<>
{
if (status === PlayerStatus.PLAYING || status === PlayerStatus.SUSPEND) {
opacity.value = withTiming(0, { duration: 300 });
diff --git a/js/components/src/components/mobile-player/use-webrtc.tsx b/js/components/src/components/mobile-player/use-webrtc.tsx
index d58faf72..045b0474 100644
--- a/js/components/src/components/mobile-player/use-webrtc.tsx
+++ b/js/components/src/components/mobile-player/use-webrtc.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
-import { usePlayerStore, useStreamKey } from "../..";
+import { PlayerStatus, usePlayerStore, useStreamKey } from "../..";
import { RTCPeerConnection, RTCSessionDescription } from "./webrtc-primitives";
export default function useWebRTC(
@@ -7,6 +7,7 @@ export default function useWebRTC(
): [MediaStream | null, boolean] {
const [mediaStream, setMediaStream] = useState(null);
const [stuck, setStuck] = useState(false);
+ const setStatus = usePlayerStore((x) => x.setStatus);
const lastChange = useRef(0);
@@ -29,7 +30,12 @@ export default function useWebRTC(
});
peerConnection.addEventListener("connectionstatechange", () => {
console.log("connection state change", peerConnection.connectionState);
- if (peerConnection.connectionState === "closed") {
+ if (
+ peerConnection.connectionState === "closed" ||
+ peerConnection.connectionState === "failed" ||
+ peerConnection.connectionState === "disconnected"
+ ) {
+ console.log("setting stuck to true", peerConnection.connectionState);
setStuck(true);
}
if (peerConnection.connectionState !== "connected") {
@@ -52,6 +58,7 @@ export default function useWebRTC(
if (lastAudioFramesReceived !== audioFramesReceived) {
lastAudioFramesReceived = audioFramesReceived;
lastChange.current = Date.now();
+ setStatus(PlayerStatus.PLAYING);
setStuck(false);
}
}
@@ -60,6 +67,7 @@ export default function useWebRTC(
if (lastFramesReceived !== framesReceived) {
lastFramesReceived = framesReceived;
lastChange.current = Date.now();
+ setStatus(PlayerStatus.PLAYING);
setStuck(false);
}
}
diff --git a/js/components/src/components/mobile-player/video-retry.tsx b/js/components/src/components/mobile-player/video-retry.tsx
index 082882bd..492420df 100644
--- a/js/components/src/components/mobile-player/video-retry.tsx
+++ b/js/components/src/components/mobile-player/video-retry.tsx
@@ -1,26 +1,17 @@
import React, { useEffect, useRef, useState } from "react";
-import { usePlayerStore } from "../..";
+import { PlayerStatus, usePlayerStore } from "../..";
export default function VideoRetry(props: { children: React.ReactNode }) {
const retryTimeoutRef = useRef(null);
const [retries, setRetries] = useState(0);
- const [hasStarted, setHasStarted] = useState(false);
-
- const offline = usePlayerStore((x) => x.offline);
+ const playing = usePlayerStore((x) => x.status === PlayerStatus.PLAYING);
useEffect(() => {
- if (!offline && !hasStarted) {
- console.log("Player is online. Marking as started.");
- setHasStarted(true);
- }
-
- if (offline) {
- console.log("Player is offline. Incrementing retries.");
- setRetries((prevRetries) => prevRetries + 1);
-
+ if (!playing) {
const jitter = 500 + Math.random() * 1500;
retryTimeoutRef.current = setTimeout(() => {
console.log("Retrying video playback...");
+ setRetries((prevRetries) => prevRetries + 1);
}, jitter);
}
@@ -28,9 +19,10 @@ export default function VideoRetry(props: { children: React.ReactNode }) {
if (retryTimeoutRef.current) {
console.log("Clearing retry timeout");
clearTimeout(retryTimeoutRef.current);
+ retryTimeoutRef.current = null;
}
};
- }, [offline, hasStarted]);
+ }, [!playing]);
return {props.children};
}
diff --git a/js/components/src/components/mobile-player/video.native.tsx b/js/components/src/components/mobile-player/video.native.tsx
index 0a39759b..5b52279e 100644
--- a/js/components/src/components/mobile-player/video.native.tsx
+++ b/js/components/src/components/mobile-player/video.native.tsx
@@ -89,6 +89,14 @@ export function NativeVideo() {
}, [setStatus]);
const player = useVideoPlayer(url, (player) => {
+ player.addListener("playingChange", (newIsPlaying) => {
+ console.log("playingChange", newIsPlaying);
+ if (newIsPlaying) {
+ setStatus(PlayerStatus.PLAYING);
+ } else {
+ setStatus(PlayerStatus.WAITING);
+ }
+ });
player.loop = true;
player.muted = muted;
player.play();
@@ -115,12 +123,14 @@ export function NativeVideo() {
).map((evType) => {
return player.addListener(evType, (...args) => {
const now = new Date();
+ console.log("video native event", evType);
playerEvent(spurl, now.toISOString(), evType, { args: args });
});
});
subs.push(
player.addListener("playingChange", (newIsPlaying) => {
+ console.log("playingChange", newIsPlaying);
if (newIsPlaying) {
setStatus(PlayerStatus.PLAYING);
} else {
@@ -164,6 +174,7 @@ export function NativeWHEP() {
PlayerProtocol.WEBRTC,
);
const [stream, stuck] = useWebRTC(url);
+ const status = usePlayerStore((x) => x.status);
const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth);
const setPlayerHeight = usePlayerStore((x) => x.setPlayerHeight);
@@ -189,22 +200,25 @@ export function NativeWHEP() {
const volume = usePlayerStore((x) => x.volume);
useEffect(() => {
- if (stuck) {
+ if (stuck && status === PlayerStatus.PLAYING) {
+ console.log("setting status to stalled", status);
setStatus(PlayerStatus.STALLED);
- } else {
+ }
+ if (!stuck && status === PlayerStatus.STALLED) {
+ console.log("setting status to playing", status);
setStatus(PlayerStatus.PLAYING);
}
- }, [stuck, setStatus]);
+ }, [stuck, status]);
const mediaStream = stream as unknown as MediaStream;
- useEffect(() => {
- if (!mediaStream) {
- setStatus(PlayerStatus.WAITING);
- return;
- }
- setStatus(PlayerStatus.PLAYING);
- }, [mediaStream, setStatus]);
+ // useEffect(() => {
+ // if (!mediaStream) {
+ // setStatus(PlayerStatus.WAITING);
+ // return;
+ // }
+ // setStatus(PlayerStatus.PLAYING);
+ // }, [mediaStream, setStatus]);
useEffect(() => {
if (!mediaStream) {
diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx
index 4f5ea800..c37ffa7d 100644
--- a/js/components/src/components/mobile-player/video.tsx
+++ b/js/components/src/components/mobile-player/video.tsx
@@ -176,6 +176,7 @@ const VideoElement = forwardRef<
firstAttempt,
videoRef: !!localVideoRef.current,
});
+ setStatus(PlayerStatus.PLAYING);
event("canplaythrough")(e);
if (firstAttempt && localVideoRef.current) {
setFirstAttempt(false);
@@ -437,7 +438,7 @@ export function WebRTCPlayerInner({
if (stuck && status === PlayerStatus.PLAYING) {
setStatus(PlayerStatus.STALLED);
}
- if (!stuck && mediaStream) {
+ if (!stuck && status === PlayerStatus.STALLED) {
setStatus(PlayerStatus.PLAYING);
}
}, [stuck, status, mediaStream]);
diff --git a/js/components/src/livestream-store/livestream-store.tsx b/js/components/src/livestream-store/livestream-store.tsx
index c73f8b8b..a3d8d636 100644
--- a/js/components/src/livestream-store/livestream-store.tsx
+++ b/js/components/src/livestream-store/livestream-store.tsx
@@ -1,4 +1,4 @@
-import { useContext } from "react";
+import { useContext, useEffect, useState } from "react";
import { createStore, StoreApi, useStore } from "zustand";
import { LivestreamContext } from "./context";
import { LivestreamState } from "./livestream-state";
@@ -58,3 +58,19 @@ export const useLivestream = () => useLivestreamStore((x) => x.livestream);
export const useSegment = () => useLivestreamStore((x) => x.segment);
export const useRenditions = () => useLivestreamStore((x) => x.renditions);
+
+// returns true if the livestream has been offline for more than 10 seconds
+export const useOffline = () => {
+ const segment = useLivestreamStore((x) => x.segment);
+ const [now, setNow] = useState(Date.now());
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setNow(Date.now());
+ }, 500);
+ return () => clearInterval(interval);
+ }, []);
+ if (!segment?.startTime) {
+ return false;
+ }
+ return now - Date.parse(segment.startTime) > 10000;
+};
diff --git a/js/components/src/player-store/player-state.tsx b/js/components/src/player-store/player-state.tsx
index 9677efdf..b17a84b6 100644
--- a/js/components/src/player-store/player-state.tsx
+++ b/js/components/src/player-store/player-state.tsx
@@ -99,10 +99,6 @@ export interface PlayerState {
setPlayTime: (playTime: number) => void;
/** Flag indicating if player is in offline state */
- offline: boolean;
-
- /** Function to set the offline state */
- setOffline: (offline: boolean) => void;
/** Reference to the video element for direct manipulation (used for PiP) */
videoRef:
| React.MutableRefObject
diff --git a/js/components/src/player-store/player-store.tsx b/js/components/src/player-store/player-store.tsx
index 8c83dd5d..712e46b4 100644
--- a/js/components/src/player-store/player-store.tsx
+++ b/js/components/src/player-store/player-store.tsx
@@ -68,9 +68,6 @@ export const makePlayerStore = (id?: string): StoreApi => {
playTime: 0,
setPlayTime: (playTime: number) => set(() => ({ playTime })),
- offline: false,
- setOffline: (offline: boolean) => set(() => ({ offline })),
-
videoRef: undefined,
setVideoRef: (
videoRef: