diff --git a/js/app/components/live-dashboard/stream-monitor.tsx b/js/app/components/live-dashboard/stream-monitor.tsx index 8c8d6e02..05d1529d 100644 --- a/js/app/components/live-dashboard/stream-monitor.tsx +++ b/js/app/components/live-dashboard/stream-monitor.tsx @@ -5,6 +5,7 @@ import { useLivestream, useLivestreamStore, usePlayerStore, + useSegmentTiming, zero, } from "@streamplace/components"; import { DesktopUi } from "components/mobile/desktop-ui"; @@ -14,7 +15,6 @@ import { useEffect, useState } from "react"; import { Image, TouchableOpacity, View } from "react-native"; import Animated from "react-native-reanimated"; import { useLiveUser } from "../../hooks/useLiveUser"; -import { useSegmentTiming } from "../../hooks/useSegmentTiming"; import StreamScreen from "./live-selector"; const { flex, bg, r, borders, layout, p, text, w, h, mt } = zero; diff --git a/js/app/components/mobile/desktop-ui.tsx b/js/app/components/mobile/desktop-ui.tsx index 8cf2b6b1..689c5a7a 100644 --- a/js/app/components/mobile/desktop-ui.tsx +++ b/js/app/components/mobile/desktop-ui.tsx @@ -54,8 +54,6 @@ export function DesktopUi({ setShowCountdown, recordSubmitted, setRecordSubmitted, - ingestStarting, - setIngestStarting, toggleGoLive, } = useLivestreamInfo(); const { width, height } = usePlayerDimensions(); @@ -112,11 +110,8 @@ export function DesktopUi({ return () => { if (fadeTimeout.current) clearTimeout(fadeTimeout.current); - if (ingestStarting) { - setIngestStarting(false); - } }; - }, [ingestStarting, setIngestStarting, resetFadeTimer]); + }, [resetFadeTimer]); const animatedFadeStyle = useAnimatedStyle(() => ({ opacity: shouldShowFloatingMetrics ? 1 : fadeOpacity.value, @@ -252,7 +247,6 @@ export function DesktopUi({ diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx index aa7aee33..c33d2bba 100644 --- a/js/app/components/mobile/ui.tsx +++ b/js/app/components/mobile/ui.tsx @@ -7,6 +7,7 @@ import { Toast, useAvatars, useCameraToggle, + useLivestream, useLivestreamInfo, useLivestreamStore, useMuted, @@ -69,8 +70,6 @@ export function MobileUi({ setShowCountdown, recordSubmitted, setRecordSubmitted, - ingestStarting, - setIngestStarting, toggleGoLive, toggleStopStream, } = useLivestreamInfo(); @@ -83,32 +82,19 @@ export function MobileUi({ const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); const muted = useMuted(); const setMuted = useSetMuted(); + const ls = useLivestream(); const { shouldShowFloatingMetrics, shouldShowChatSidePanel, chatPanelWidth } = useResponsiveLayout(); const [showLoading, setShowLoading] = useState(false); - useEffect(() => { - return () => { - if (ingestStarting) { - setIngestStarting(false); - } - }; - }, [ingestStarting, setIngestStarting]); - useEffect(() => { if (recordSubmitted) setShowLoading(false); }, [recordSubmitted]); - const isSelfAndNotLive = ingest === "new"; - const isLive = ingest !== null && ingest !== "new"; - - useEffect(() => { - if (isLive && ingestStarting) { - setIngestStarting(false); - } - }, [isLive, ingestStarting, setIngestStarting]); + const isSelfAndNotLive = ingest !== null && ls === null; + const isSelfAndLive = ingest !== null && ls !== null; const FADE_OUT_DELAY = 4000; const fadeOpacity = useSharedValue(1); @@ -225,7 +211,7 @@ export function MobileUi({ - {shouldShowFloatingMetrics && isLive && ( + {shouldShowFloatingMetrics && isSelfAndLive && ( )} @@ -281,7 +266,7 @@ export function MobileUi({ - {showChat === undefined && ingest !== "new" && ( + {showChat === undefined && !isSelfAndNotLive && ( )} diff --git a/js/app/hooks/useSegmentTiming.tsx b/js/app/hooks/useSegmentTiming.tsx deleted file mode 100644 index cd6a8e52..00000000 --- a/js/app/hooks/useSegmentTiming.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useLivestreamStore } from "@streamplace/components"; -import { useEffect, useRef, useState } from "react"; - -export type ConnectionQuality = "good" | "degraded" | "poor"; - -function getLiveConnectionQuality( - timeBetweenSegments: number | null, - range: number | null, - numOfSegments: number = 1, -): ConnectionQuality { - if (timeBetweenSegments === null || range === null) return "poor"; - - if (timeBetweenSegments <= 1500 && range <= (1500 * 60) / numOfSegments) { - return "good"; - } - if (timeBetweenSegments <= 3000 && range <= (3000 * 60) / numOfSegments) { - return "degraded"; - } - return "poor"; -} - -export function useSegmentTiming() { - const latestSegment = useLivestreamStore((x) => x.segment); - const [segmentDeltas, setSegmentDeltas] = useState([]); - const prevSegmentRef = useRef(); - const prevTimestampRef = useRef(null); - - // Dummy state to force update every second - const [, setNow] = useState(Date.now()); - - useEffect(() => { - const interval = setInterval(() => { - setNow(Date.now()); - }, 1000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - if (latestSegment && prevSegmentRef.current !== latestSegment) { - const now = Date.now(); - if (prevTimestampRef.current !== null) { - const delta = now - prevTimestampRef.current; - // Only store the last 25 deltas - setSegmentDeltas((prev) => [...prev, delta].slice(-25)); - } - prevTimestampRef.current = now; - prevSegmentRef.current = latestSegment; - } - }, [latestSegment]); - - // The most recent time between segments - const timeBetweenSegments = - segmentDeltas.length > 0 - ? segmentDeltas[segmentDeltas.length - 1] - : prevTimestampRef.current - ? Date.now() - prevTimestampRef.current - : null; - - // Calculate mean and range of deltas - const mean = - segmentDeltas.length > 0 - ? Math.round( - segmentDeltas.reduce((acc, curr) => acc + curr, 0) / - segmentDeltas.length, - ) - : null; - - const range = - segmentDeltas.length > 0 - ? Math.max(...segmentDeltas) - Math.min(...segmentDeltas) - : null; - - let to_ret = { - segmentDeltas, - timeBetweenSegments, - mean, - range, - connectionQuality: "poor", - }; - - to_ret.connectionQuality = getLiveConnectionQuality( - timeBetweenSegments, - range, - segmentDeltas.length, - ); - - return to_ret; -} diff --git a/js/app/src/router.tsx b/js/app/src/router.tsx index 055b7a4f..c69c71cc 100644 --- a/js/app/src/router.tsx +++ b/js/app/src/router.tsx @@ -542,7 +542,7 @@ export function StreamplaceDrawer() { // are we in the live dashboard? const [isLiveDashboard, setIsLiveDashboard] = useState(false); useEffect(() => { - if (!isLiveDashboard && userIsLive) { + if (!isLiveDashboard && userIsLive && isWeb) { toast.show( "You are streaming!", "Do you want to go to your Live Dashboard?", diff --git a/js/components/src/components/dashboard/header.tsx b/js/components/src/components/dashboard/header.tsx index f10710eb..9b6d94f1 100644 --- a/js/components/src/components/dashboard/header.tsx +++ b/js/components/src/components/dashboard/header.tsx @@ -8,7 +8,7 @@ interface MetricItemProps { icon: any; label: string; value: string; - status?: "good" | "warning" | "error"; + status?: "good" | "warning" | "error" | "pre-live"; } function MetricItem({ icon: Icon, label, value, status }: MetricItemProps) { diff --git a/js/components/src/components/mobile-player/ui/input.tsx b/js/components/src/components/mobile-player/ui/input.tsx index ac5888ed..3fa09e1a 100644 --- a/js/components/src/components/mobile-player/ui/input.tsx +++ b/js/components/src/components/mobile-player/ui/input.tsx @@ -7,7 +7,6 @@ const { gap, h, layout, mt, p, position, px, py, sizes, w } = atoms; type InputPanelProps = { title: string | undefined; setTitle: (title: string) => void; - ingestStarting: boolean; toggleGoLive: () => void; isLive: boolean; toggleStopStream?: () => void; @@ -16,7 +15,6 @@ type InputPanelProps = { export function InputPanel({ title, setTitle, - ingestStarting, toggleGoLive, isLive, toggleStopStream, @@ -51,9 +49,7 @@ export function InputPanel({ /> )} - {ingestStarting ? ( - Starting your stream... - ) : isLive ? ( + {isLive ? ( ; let color = "#d44"; - if (connectionQuality === "good") { + if (connectionQuality === "pre-live") { + icon = ; + color = atoms.colors.blue[500]; + } else if (connectionQuality === "good") { icon = ; color = "#4d4"; } else if (connectionQuality === "degraded") { @@ -23,6 +26,18 @@ export function MetricsPanel({ showMetrics }: MetricsPanelProps) { color = "#d44"; } + const connectionText = () => { + if (connectionQuality === "pre-live") { + return "READY TO STREAM"; + } else if (connectionQuality === "good") { + return "GOOD"; + } else if (connectionQuality === "degraded") { + return "DEGRADED"; + } else { + return "POOR"; + } + }; + return ( - {connectionQuality.toUpperCase()} + {connectionText()} {showMetrics && ( diff --git a/js/components/src/components/mobile-player/video-async.native.tsx b/js/components/src/components/mobile-player/video-async.native.tsx index 821c236b..ce3cd04a 100644 --- a/js/components/src/components/mobile-player/video-async.native.tsx +++ b/js/components/src/components/mobile-player/video-async.native.tsx @@ -285,9 +285,9 @@ export function NativeWHEP(props?: { export function NativeIngestPlayer(props?: { objectFit?: "contain" | "cover"; }) { - const ingestStarting = useIngestPlayerStore((x) => x.ingestStarting); const ingestMediaSource = useIngestPlayerStore((x) => x.ingestMediaSource); const ingestAutoStart = useIngestPlayerStore((x) => x.ingestAutoStart); + const setIngestLive = useIngestPlayerStore((x) => x.setIngestLive); const setStatus = useIngestPlayerStore((x) => x.setStatus); const setVideoRef = usePlayerStore((x) => x.setVideoRef); @@ -377,17 +377,19 @@ export function NativeIngestPlayer(props?: { }, [ingestMediaSource, ingestCamera]); useEffect(() => { - if (!ingestStarting && !ingestAutoStart) { - setRemoteMediaStream(null); - return; + if (localMediaStream) { + setIngestLive(true); } + }, [localMediaStream]); + + useEffect(() => { if (!localMediaStream) { return; } console.log("setting remote media stream", localMediaStream); // @ts-expect-error: WebRTCMediaStream may not have all MediaStream properties, but is compatible for our use setRemoteMediaStream(localMediaStream); - }, [localMediaStream, ingestStarting, ingestAutoStart, setRemoteMediaStream]); + }, [localMediaStream, ingestAutoStart, setRemoteMediaStream]); if (!localMediaStream) { return null; diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx index ace0d103..fb57b1ed 100644 --- a/js/components/src/components/mobile-player/video.tsx +++ b/js/components/src/components/mobile-player/video.tsx @@ -542,7 +542,6 @@ export function WebRTCPlayerInner({ } export function WebcamIngestPlayer(props: VideoProps) { - const ingestStarting = usePlayerStore((x) => x.ingestStarting); const ingestMediaSource = usePlayerStore((x) => x.ingestMediaSource); const ingestAutoStart = usePlayerStore((x) => x.ingestAutoStart); @@ -607,7 +606,7 @@ export function WebcamIngestPlayer(props: VideoProps) { }, [ingestMediaSource]); useEffect(() => { - if (!ingestStarting && !ingestAutoStart) { + if (!ingestAutoStart) { setRemoteMediaStream(null); return; } @@ -615,7 +614,7 @@ export function WebcamIngestPlayer(props: VideoProps) { return; } setRemoteMediaStream(localMediaStream); - }, [localMediaStream, ingestStarting, ingestAutoStart]); + }, [localMediaStream, ingestAutoStart]); useEffect(() => { if (!videoElement) { diff --git a/js/components/src/hooks/useLivestreamInfo.ts b/js/components/src/hooks/useLivestreamInfo.ts index 690a1142..3bef6835 100644 --- a/js/components/src/hooks/useLivestreamInfo.ts +++ b/js/components/src/hooks/useLivestreamInfo.ts @@ -6,8 +6,6 @@ import { useCreateStreamRecord } from "../streamplace-store"; export function useLivestreamInfo(url?: string) { const ingest = usePlayerStore((x) => x.ingestConnectionState); const profile = useLivestreamStore((x) => x.profile); - const ingestStarting = usePlayerStore((x) => x.ingestStarting); - const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); const setIngestLive = usePlayerStore((x) => x.setIngestLive); const stopIngest = usePlayerStore((x) => x.stopIngest); @@ -39,20 +37,13 @@ export function useLivestreamInfo(url?: string) { keyboardHeight?: number, closeKeyboard?: () => void, ) => { - if (!ingestStarting) { - // Optionally close keyboard if provided - if (closeKeyboard) closeKeyboard(); - setShowCountdown(true); - setIngestStarting(true); - setIngestLive(true); - // wait ~3 seconds before announcing - setTimeout(() => { - handleSubmit(); - }, 3000); - } else { - setIngestStarting(false); - setIngestLive(false); - } + // Optionally close keyboard if provided + if (closeKeyboard) closeKeyboard(); + setShowCountdown(true); + // wait ~3 seconds before announcing + setTimeout(() => { + handleSubmit(); + }, 3000); }; // Stop the current broadcast @@ -70,8 +61,6 @@ export function useLivestreamInfo(url?: string) { setShowCountdown, recordSubmitted, setRecordSubmitted, - ingestStarting, - setIngestStarting, handleSubmit, toggleGoLive, toggleStopStream, diff --git a/js/components/src/hooks/useSegmentTiming.tsx b/js/components/src/hooks/useSegmentTiming.tsx index d01f346a..94571052 100644 --- a/js/components/src/hooks/useSegmentTiming.tsx +++ b/js/components/src/hooks/useSegmentTiming.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; -import { useLivestreamStore } from "../livestream-store"; +import { useLivestream, useLivestreamStore } from "../livestream-store"; -export type ConnectionQuality = "good" | "degraded" | "poor"; +export type ConnectionQuality = "good" | "degraded" | "poor" | "pre-live"; function getLiveConnectionQuality( timeBetweenSegments: number | null, @@ -24,6 +24,7 @@ export function useSegmentTiming() { const [segmentDeltas, setSegmentDeltas] = useState([]); const prevSegmentRef = useRef(); const prevTimestampRef = useRef(null); + const ls = useLivestream(); // Dummy state to force update every second const [, setNow] = useState(Date.now()); @@ -84,5 +85,9 @@ export function useSegmentTiming() { segmentDeltas.length, ); + if (!ls) { + to_ret.connectionQuality = "pre-live"; + } + return to_ret; } diff --git a/js/components/src/player-store/player-state.tsx b/js/components/src/player-store/player-state.tsx index ac885209..fa48bc21 100644 --- a/js/components/src/player-store/player-state.tsx +++ b/js/components/src/player-store/player-state.tsx @@ -38,12 +38,6 @@ export interface PlayerState { /** Function to set the source URL */ setSrc: (src: string) => void; - /** Flag indicating if ingest (stream input) is currently starting */ - ingestStarting: boolean; - - /** Function to set the ingestStarting flag */ - setIngestStarting: (ingestStarting: boolean) => void; - /** Flag indicating if ingest is live */ ingestLive: boolean; setIngestLive: (ingestLive: boolean) => void; diff --git a/js/components/src/player-store/player-store.tsx b/js/components/src/player-store/player-store.tsx index 115dfc94..4eb3ea0b 100644 --- a/js/components/src/player-store/player-store.tsx +++ b/js/components/src/player-store/player-store.tsx @@ -28,10 +28,6 @@ export const makePlayerStore = (id?: string): StoreApi => { src: "", setSrc: (src: string) => set(() => ({ src })), - ingestStarting: false, - setIngestStarting: (ingestStarting: boolean) => - set(() => ({ ingestStarting })), - ingestMediaSource: undefined, setIngestMediaSource: (ingestMediaSource: IngestMediaSource | undefined) => set(() => ({ ingestMediaSource })), @@ -45,7 +41,7 @@ export const makePlayerStore = (id?: string): StoreApi => { ingestConnectionState: RTCPeerConnectionState | null, ) => set(() => ({ ingestConnectionState })), - ingestAutoStart: false, + ingestAutoStart: true, setIngestAutoStart: (ingestAutoStart: boolean) => set(() => ({ ingestAutoStart })), diff --git a/js/components/src/player-store/single-player-provider.tsx b/js/components/src/player-store/single-player-provider.tsx index 5effed54..950a39e1 100644 --- a/js/components/src/player-store/single-player-provider.tsx +++ b/js/components/src/player-store/single-player-provider.tsx @@ -143,16 +143,12 @@ export function useCurrentPlayerRendition(): [ * Hook to get the ingest state of the current player */ export function useCurrentPlayerIngest(): { - starting: boolean; - setStarting: (starting: boolean) => void; connectionState: RTCPeerConnectionState | null; setConnectionState: (state: RTCPeerConnectionState | null) => void; startedTimestamp: number | null; setStartedTimestamp: (timestamp: number | null) => void; } { return useCurrentPlayerStore((state) => ({ - starting: state.ingestStarting, - setStarting: state.setIngestStarting, connectionState: state.ingestConnectionState, setConnectionState: state.setIngestConnectionState, startedTimestamp: state.ingestStarted, diff --git a/js/components/src/streamplace-store/stream.tsx b/js/components/src/streamplace-store/stream.tsx index c1776e2a..a5c7d061 100644 --- a/js/components/src/streamplace-store/stream.tsx +++ b/js/components/src/streamplace-store/stream.tsx @@ -156,50 +156,50 @@ export function useCreateStreamRecord() { const u = new URL(url); - let thumbnail: BlobRef | undefined = undefined; - - if (customThumbnail) { - try { - thumbnail = await uploadThumbnail(agent, customThumbnail); - } catch (e) { - throw new Error(`Custom thumbnail upload failed ${e}`); - } - } else { - // No custom thumbnail: fetch the server-side image and upload it - // try thrice lel - let tries = 0; - try { - for (; tries < 3; tries++) { - try { - console.log( - `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, - ); - const thumbnailRes = await fetch( - `${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, - ); - if (!thumbnailRes.ok) { - throw new Error( - `Failed to fetch thumbnail: ${thumbnailRes.status})`, - ); - } - const thumbnailBlob = await thumbnailRes.blob(); - console.log(thumbnailBlob); - thumbnail = await uploadThumbnail(agent, thumbnailBlob); - } catch (e) { - console.warn( - `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`, - ); - // Wait 1 second before retrying - await new Promise((resolve) => setTimeout(resolve, 2000)); - if (tries === 2) { - throw new Error(`Failed to fetch thumbnail after 3 tries: ${e}`); - } - } - } - } catch (e) { - throw new Error(`Thumbnail upload failed ${e}`); - } - } + // let thumbnail: BlobRef | undefined = undefined; + + // if (customThumbnail) { + // try { + // thumbnail = await uploadThumbnail(agent, customThumbnail); + // } catch (e) { + // throw new Error(`Custom thumbnail upload failed ${e}`); + // } + // } else { + // // No custom thumbnail: fetch the server-side image and upload it + // // try thrice lel + // let tries = 0; + // try { + // for (; tries < 3; tries++) { + // try { + // console.log( + // `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, + // ); + // const thumbnailRes = await fetch( + // `${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, + // ); + // if (!thumbnailRes.ok) { + // throw new Error( + // `Failed to fetch thumbnail: ${thumbnailRes.status})`, + // ); + // } + // const thumbnailBlob = await thumbnailRes.blob(); + // console.log(thumbnailBlob); + // thumbnail = await uploadThumbnail(agent, thumbnailBlob); + // } catch (e) { + // console.warn( + // `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`, + // ); + // // Wait 1 second before retrying + // await new Promise((resolve) => setTimeout(resolve, 2000)); + // if (tries === 2) { + // throw new Error(`Failed to fetch thumbnail after 3 tries: ${e}`); + // } + // } + // } + // } catch (e) { + // throw new Error(`Thumbnail upload failed ${e}`); + // } + // } let newPost: undefined | { uri: string; cid: string } = undefined; @@ -221,7 +221,7 @@ export function useCreateStreamRecord() { u, profile.data, params, - thumbnail, + undefined, agent, ); @@ -264,7 +264,7 @@ export function useCreateStreamRecord() { // e.g. `@streamplace/components/0.1.0 (ios, 32.0)` agent: `@streamplace/components/${PackageJson.version} (${platform}, ${platVersion})`, post: newPost, - thumb: thumbnail, + // thumb: thumbnail, idleTimeoutSeconds: idleTimeoutSeconds, }; console.log("record", record); diff --git a/pkg/api/playback.go b/pkg/api/playback.go index cdae6e22..87b49c8e 100644 --- a/pkg/api/playback.go +++ b/pkg/api/playback.go @@ -16,6 +16,7 @@ import ( "stream.place/streamplace/pkg/errors" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/spmetrics" + "stream.place/streamplace/pkg/streamplace" ) func (a *StreamplaceAPI) NormalizeUser(ctx context.Context, user string) (string, error) { @@ -272,6 +273,29 @@ func (a *StreamplaceAPI) HandleThumbnailPlayback(ctx context.Context) httprouter errors.WriteHTTPNotFound(w, "user not found", err) return } + ls, err := a.Model.GetLatestLivestreamForRepo(user) + if err != nil { + errors.WriteHTTPInternalServerError(w, "could not get livestream", err) + return + } + if ls == nil { + errors.WriteHTTPNotFound(w, "livestream not found", err) + return + } + lsrv, err := ls.ToLivestreamView() + if err != nil { + errors.WriteHTTPInternalServerError(w, "could not marshal livestream", err) + return + } + lsr, ok := lsrv.Record.Val.(*streamplace.Livestream) + if !ok { + errors.WriteHTTPInternalServerError(w, "livestream is not a streamplace livestream", nil) + return + } + if lsr.EndedAt != nil { + errors.WriteHTTPNotFound(w, "livestream has ended", nil) + return + } thumb, err := a.LocalDB.LatestThumbnailForUser(user) if err != nil { errors.WriteHTTPInternalServerError(w, "could not query thumbnail", err) diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go index e1746063..8ff036b0 100644 --- a/pkg/director/stream_session.go +++ b/pkg/director/stream_session.go @@ -195,17 +195,17 @@ func (ss *StreamSession) NewSegment(ctx context.Context, notif *media.NewSegment return ss.statefulDB.UpsertBroadcastOrigin(spseg.Creator, ss.cli.BroadcasterDID(), time.Now()) }) - // everything else is for published segments - if !notif.Metadata.Published { - return nil - } - if ss.cli.Thumbnail { ss.Go(ctx, func() error { return ss.Thumbnail(ctx, spseg.Creator, notif) }) } + // everything else is for published segments + if !notif.Metadata.Published { + return nil + } + if notif.Local { ss.UpdateStatus(ctx, spseg.Creator) ss.UpdateBroadcastOrigin(ctx)