Live video on the AT Protocol

live-dashboard: first pass at visual changes for pre-live

+85 -21
+27 -5
js/app/components/live-dashboard/livestream-panel.tsx
··· 209 209 const userIsLive = useLiveUser(); 210 210 const captureFrame = useCaptureVideoFrame(); 211 211 const profile = useUserProfile(); 212 - const livestream = useLivestream(); 212 + const livestream = useLivestream(true); 213 213 const createStreamRecord = useCreateStreamRecord(); 214 214 const updateStreamRecord = useUpdateStreamRecord(); 215 215 const endLivestream = useEndLivestream(); 216 216 const url = useUrl(); 217 + const [endingLivestream, setEndingLivestream] = useState(false); 217 218 218 219 const [title, setTitle] = useState(""); 219 220 const [loading, setLoading] = useState(false); ··· 352 353 353 354 const handleEndLivestream = useCallback(async () => { 354 355 if (!livestream) return; 355 - await endLivestream(livestream); 356 + setEndingLivestream(true); 357 + try { 358 + await endLivestream(livestream); 359 + } catch (error) { 360 + console.error("Error ending livestream:", error); 361 + toast.show("Error", "Failed to end livestream", { 362 + duration: 3, 363 + }); 364 + } 356 365 }, [livestream, endLivestream]); 366 + 367 + useEffect(() => { 368 + if (livestream && livestream.record.endedAt !== undefined) { 369 + setEndingLivestream(false); 370 + } 371 + }, [livestream]); 357 372 358 373 const handleImageSelect = useCallback(() => { 359 374 // Default web file picker behavior ··· 418 433 showsVerticalScrollIndicator: false, 419 434 } 420 435 : {}; 436 + 437 + const canEndLivestream = 438 + livestream && livestream.record.endedAt === undefined; 421 439 422 440 return ( 423 441 <> ··· 668 686 </Text> 669 687 </Button> 670 688 <Button 671 - variant="destructive" 689 + variant={canEndLivestream ? "destructive" : "secondary"} 672 690 onPress={handleEndLivestream} 673 691 style={[ 674 692 r.md, 675 693 py[3], 676 694 w.percent[100], 677 695 layout.flex.center, 678 - { opacity: disabled ? 0.5 : 1 }, 696 + { 697 + opacity: !canEndLivestream ? 0.5 : 1, 698 + cursor: canEndLivestream ? "pointer" : "not-allowed", 699 + }, 679 700 ]} 701 + disabled={!canEndLivestream || endingLivestream} 680 702 > 681 703 <Text 682 704 style={[text.white, { fontSize: 16, fontWeight: "bold" }]} 683 705 > 684 - End Livestream 706 + {endingLivestream ? "Ending Livestream..." : "End Livestream"} 685 707 </Text> 686 708 </Button> 687 709 </View>
+36 -11
js/app/components/live-dashboard/stream-monitor.tsx
··· 33 33 const isUserLive = useLiveUser(); 34 34 const profile = useLivestreamStore((x) => x.profile); 35 35 const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState); 36 - const ls = useLivestream(); 36 + let ls = useLivestream(); 37 37 const segmentTiming = useSegmentTiming(); 38 38 39 39 // Use hook data primarily, fallback to props ··· 88 88 return "red"; 89 89 } 90 90 }; 91 + 92 + const getStreamStatus = () => { 93 + if (!isLive) return "OFFLINE"; 94 + if (!ls) return "NOT LIVE"; 95 + return "LIVE"; 96 + }; 97 + 98 + const getStreamTitle = () => { 99 + if (!ls) { 100 + return ( 101 + <Text 102 + style={[ 103 + text.white, 104 + { fontSize: 14, fontWeight: "400", fontStyle: "italic" }, 105 + ]} 106 + numberOfLines={1} 107 + ellipsizeMode="tail" 108 + > 109 + Stream not live yet. Press "Announce Livestream" to start! 110 + </Text> 111 + ); 112 + } 113 + return ( 114 + <Text 115 + style={[text.white, { fontSize: 18, fontWeight: "600" }]} 116 + numberOfLines={1} 117 + ellipsizeMode="tail" 118 + > 119 + {ls?.record.title || "Stream Title"} 120 + </Text> 121 + ); 122 + }; 123 + 91 124 return ( 92 125 <View 93 126 style={[ ··· 166 199 { flex: 1, minWidth: 0, gap: 12 }, 167 200 ]} 168 201 > 169 - <View style={{ flex: 1, minWidth: 0 }}> 170 - <Text 171 - style={[text.white, { fontSize: 18, fontWeight: "600" }]} 172 - numberOfLines={1} 173 - ellipsizeMode="tail" 174 - > 175 - {ls?.record.title || "Stream Title"} 176 - </Text> 177 - </View> 202 + <View style={{ flex: 1, minWidth: 0 }}>{getStreamTitle()}</View> 178 203 <View 179 204 style={[ 180 205 layout.flex.row, ··· 209 234 ]} 210 235 /> 211 236 <Text style={[text.gray[400], { fontSize: 14 }]}> 212 - {isLive ? "LIVE" : "OFFLINE"} 237 + {getStreamStatus()} 213 238 </Text> 214 239 </View> 215 240 </View>
+7 -1
js/components/src/livestream-store/livestream-store.tsx
··· 62 62 63 63 export const useViewers = () => useLivestreamStore((x) => x.viewers); 64 64 65 - export const useLivestream = () => useLivestreamStore((x) => x.livestream); 65 + export const useLivestream = (includeEnded: boolean = false) => 66 + useLivestreamStore((x) => { 67 + const ls = x.livestream; 68 + if (!ls) return null; 69 + if (!includeEnded && ls.record.endedAt !== undefined) return null; 70 + return ls; 71 + }); 66 72 67 73 export const useSegment = () => useLivestreamStore((x) => x.segment); 68 74
+1 -1
pkg/api/playback.go
··· 56 56 offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} 57 57 var answer *webrtc.SessionDescription 58 58 if a.CLI.NewWebRTCPlayback { 59 - answer, err = a.MediaManager.WebRTCPlayback2(ctx, user, rendition, &offer) 59 + answer, err = a.MediaManager.WebRTCPlayback2(ctx, user, rendition, &offer, "") 60 60 } else { 61 61 answer, err = a.MediaManager.WebRTCPlayback(ctx, user, rendition, &offer) 62 62 }
+5 -1
pkg/media/webrtc_playback2.go
··· 14 14 ) 15 15 16 16 // This function remains in scope for the duration of a single users' playback 17 - func (mm *MediaManager) WebRTCPlayback2(ctx context.Context, user string, rendition string, offer *webrtc.SessionDescription) (*webrtc.SessionDescription, error) { 17 + func (mm *MediaManager) WebRTCPlayback2(ctx context.Context, user string, rendition string, offer *webrtc.SessionDescription, viewer string) (*webrtc.SessionDescription, error) { 18 18 uu, err := uuid.NewV7() 19 19 if err != nil { 20 20 return nil, err ··· 93 93 return 94 94 case file := <-segChan.C: 95 95 log.Debug(ctx, "got segment", "file", file.Filepath) 96 + if !file.Published && viewer != user { 97 + log.Warn(ctx, "segment is not published and viewer is not the user", "viewer", viewer, "user", user) 98 + continue 99 + } 96 100 latency += file.PacketizedData.Duration 97 101 packetQueue <- file.PacketizedData 98 102 }
+1 -1
pkg/media/webrtc_playback2_test.go
··· 17 17 Type: webrtc.SDPTypeOffer, 18 18 SDP: firefoxNoH264SDP, 19 19 } 20 - answer, err := mm.WebRTCPlayback2(context.Background(), "test-user", "test-rendition", offer) 20 + answer, err := mm.WebRTCPlayback2(context.Background(), "test-user", "test-rendition", offer, "") 21 21 require.ErrorContains(t, err, "RTPSender created with no codecs") 22 22 require.Nil(t, answer) 23 23 }
+8 -1
pkg/spxrpc/place_stream_playback.go
··· 8 8 9 9 "github.com/labstack/echo/v4" 10 10 "github.com/pion/webrtc/v4" 11 + "github.com/streamplace/oatproxy/pkg/oatproxy" 11 12 ) 12 13 13 14 func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition string, streamer string, r io.Reader, _contentType string) (io.Reader, error) { 15 + 14 16 if streamer == "" { 15 17 return nil, echo.NewHTTPError(http.StatusBadRequest, "streamer is required") 16 18 } 17 19 if rendition == "" { 18 20 return nil, echo.NewHTTPError(http.StatusBadRequest, "rendition is required") 19 21 } 22 + viewer := "" 23 + session, _ := oatproxy.GetOAuthSession(ctx) 24 + if session != nil { 25 + viewer = session.DID 26 + } 20 27 repo, err := s.ATSync.SyncBlueskyRepoCached(ctx, streamer) 21 28 if err != nil { 22 29 return nil, err ··· 26 33 return nil, echo.NewHTTPError(http.StatusBadRequest, "error reading body", err) 27 34 } 28 35 offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} 29 - answer, err := s.mm.WebRTCPlayback2(ctx, repo.DID, rendition, &offer) 36 + answer, err := s.mm.WebRTCPlayback2(ctx, repo.DID, rendition, &offer, viewer) 30 37 if err != nil { 31 38 return nil, echo.NewHTTPError(http.StatusInternalServerError, "error playing back", err) 32 39 }