tangled
alpha
login
or
join now
stream.place
/
streamplace
74
fork
atom
Live video on the AT Protocol
74
fork
atom
overview
issues
1
pulls
pipelines
live-dashboard: first pass at visual changes for pre-live
Eli Mallon
2 weeks ago
3d308438
ebea3040
+85
-21
7 changed files
expand all
collapse all
unified
split
js
app
components
live-dashboard
livestream-panel.tsx
stream-monitor.tsx
components
src
livestream-store
livestream-store.tsx
pkg
api
playback.go
media
webrtc_playback2.go
webrtc_playback2_test.go
spxrpc
place_stream_playback.go
+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
212
-
const livestream = useLivestream();
212
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
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
355
-
await endLivestream(livestream);
356
356
+
setEndingLivestream(true);
357
357
+
try {
358
358
+
await endLivestream(livestream);
359
359
+
} catch (error) {
360
360
+
console.error("Error ending livestream:", error);
361
361
+
toast.show("Error", "Failed to end livestream", {
362
362
+
duration: 3,
363
363
+
});
364
364
+
}
356
365
}, [livestream, endLivestream]);
366
366
+
367
367
+
useEffect(() => {
368
368
+
if (livestream && livestream.record.endedAt !== undefined) {
369
369
+
setEndingLivestream(false);
370
370
+
}
371
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
436
+
437
437
+
const canEndLivestream =
438
438
+
livestream && livestream.record.endedAt === undefined;
421
439
422
440
return (
423
441
<>
···
668
686
</Text>
669
687
</Button>
670
688
<Button
671
671
-
variant="destructive"
689
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
678
-
{ opacity: disabled ? 0.5 : 1 },
696
696
+
{
697
697
+
opacity: !canEndLivestream ? 0.5 : 1,
698
698
+
cursor: canEndLivestream ? "pointer" : "not-allowed",
699
699
+
},
679
700
]}
701
701
+
disabled={!canEndLivestream || endingLivestream}
680
702
>
681
703
<Text
682
704
style={[text.white, { fontSize: 16, fontWeight: "bold" }]}
683
705
>
684
684
-
End Livestream
706
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
36
-
const ls = useLivestream();
36
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
91
+
92
92
+
const getStreamStatus = () => {
93
93
+
if (!isLive) return "OFFLINE";
94
94
+
if (!ls) return "NOT LIVE";
95
95
+
return "LIVE";
96
96
+
};
97
97
+
98
98
+
const getStreamTitle = () => {
99
99
+
if (!ls) {
100
100
+
return (
101
101
+
<Text
102
102
+
style={[
103
103
+
text.white,
104
104
+
{ fontSize: 14, fontWeight: "400", fontStyle: "italic" },
105
105
+
]}
106
106
+
numberOfLines={1}
107
107
+
ellipsizeMode="tail"
108
108
+
>
109
109
+
Stream not live yet. Press "Announce Livestream" to start!
110
110
+
</Text>
111
111
+
);
112
112
+
}
113
113
+
return (
114
114
+
<Text
115
115
+
style={[text.white, { fontSize: 18, fontWeight: "600" }]}
116
116
+
numberOfLines={1}
117
117
+
ellipsizeMode="tail"
118
118
+
>
119
119
+
{ls?.record.title || "Stream Title"}
120
120
+
</Text>
121
121
+
);
122
122
+
};
123
123
+
91
124
return (
92
125
<View
93
126
style={[
···
166
199
{ flex: 1, minWidth: 0, gap: 12 },
167
200
]}
168
201
>
169
169
-
<View style={{ flex: 1, minWidth: 0 }}>
170
170
-
<Text
171
171
-
style={[text.white, { fontSize: 18, fontWeight: "600" }]}
172
172
-
numberOfLines={1}
173
173
-
ellipsizeMode="tail"
174
174
-
>
175
175
-
{ls?.record.title || "Stream Title"}
176
176
-
</Text>
177
177
-
</View>
202
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
212
-
{isLive ? "LIVE" : "OFFLINE"}
237
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
65
-
export const useLivestream = () => useLivestreamStore((x) => x.livestream);
65
65
+
export const useLivestream = (includeEnded: boolean = false) =>
66
66
+
useLivestreamStore((x) => {
67
67
+
const ls = x.livestream;
68
68
+
if (!ls) return null;
69
69
+
if (!includeEnded && ls.record.endedAt !== undefined) return null;
70
70
+
return ls;
71
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
59
-
answer, err = a.MediaManager.WebRTCPlayback2(ctx, user, rendition, &offer)
59
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
17
-
func (mm *MediaManager) WebRTCPlayback2(ctx context.Context, user string, rendition string, offer *webrtc.SessionDescription) (*webrtc.SessionDescription, error) {
17
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
96
+
if !file.Published && viewer != user {
97
97
+
log.Warn(ctx, "segment is not published and viewer is not the user", "viewer", viewer, "user", user)
98
98
+
continue
99
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
20
-
answer, err := mm.WebRTCPlayback2(context.Background(), "test-user", "test-rendition", offer)
20
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
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
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
22
+
viewer := ""
23
23
+
session, _ := oatproxy.GetOAuthSession(ctx)
24
24
+
if session != nil {
25
25
+
viewer = session.DID
26
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
29
-
answer, err := s.mm.WebRTCPlayback2(ctx, repo.DID, rendition, &offer)
36
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
}