Live video on the AT Protocol

rtmp_push: translations, bugfixes, wire it up

+246 -102
-1
Makefile
··· 177 177 -D "gst-plugins-good:videobox=enabled" \ 178 178 -D "gst-plugins-good:jpeg=enabled" \ 179 179 -D "gst-plugins-good:audioparsers=enabled" \ 180 - -D "gst-plugins-good:flv=enabled" \ 181 180 -D "gst-plugins-bad:videoparsers=enabled" \ 182 181 -D "gst-plugins-bad:mpegtsmux=enabled" \ 183 182 -D "gst-plugins-bad:mpegtsdemux=enabled" \
+7
js/app/components/live-dashboard/bento-grid.tsx
··· 1 1 import { useNavigation } from "@react-navigation/native"; 2 2 import { 3 + borders, 3 4 Button, 4 5 Dashboard, 5 6 useLivestreamStore, ··· 20 21 import { useSafeAreaInsets } from "react-native-safe-area-context"; 21 22 import { useEmojiData } from "utils/emoji"; 22 23 import LivestreamPanel from "./livestream-panel"; 24 + import MultistreamStatus from "./multistream-status"; 23 25 import StreamMonitor from "./stream-monitor"; 24 26 25 27 const { flex, p, gap, layout, bg } = zero; ··· 192 194 { maxWidth: isWeb ? 600 : "100%" }, 193 195 ]} 194 196 > 197 + <View 198 + style={[borders.top.width.thin, borders.top.color.neutral[700]]} 199 + > 200 + <MultistreamStatus /> 201 + </View> 195 202 <Dashboard.ChatPanel 196 203 isLive={isLive} 197 204 isConnected={isConnected}
-4
js/app/components/live-dashboard/livestream-panel.tsx
··· 28 28 import { useUserProfile } from "store/hooks"; 29 29 import { useCaptureVideoFrame } from "../../hooks/useCaptureVideoFrame"; 30 30 import { useLiveUser } from "../../hooks/useLiveUser"; 31 - import MultistreamStatus from "./multistream-status"; 32 31 33 32 const { flex, p, px, py, gap, layout, bg, borders, text, r, w, typography } = 34 33 zero; ··· 593 592 </Button> 594 593 </View> 595 594 )} 596 - </View> 597 - <View style={[borders.top.width.thin, borders.top.color.neutral[700]]}> 598 - <MultistreamStatus /> 599 595 </View> 600 596 </Wrapper> 601 597 </>
+107 -91
js/app/components/settings/multistream-manager.tsx
··· 4 4 DialogFooter, 5 5 Input, 6 6 Text, 7 + TFunction, 8 + useTranslation, 9 + zero, 7 10 } from "@streamplace/components"; 8 - import { ThemeProvider } from "@streamplace/components/src/lib/theme/theme"; 9 11 import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; 10 - import { 11 - flex, 12 - gap, 13 - layout, 14 - mb, 15 - mt, 16 - mx, 17 - text, 18 - w, 19 - } from "@streamplace/components/src/ui"; 12 + import { gap, layout, mb, mt, text, w } from "@streamplace/components/src/ui"; 20 13 import Loading from "components/loading/loading"; 21 14 import { Plus, RefreshCw } from "lucide-react-native"; 22 15 import { useEffect, useState } from "react"; ··· 33 26 record: PlaceStreamMultistreamTarget.Record; 34 27 } 35 28 36 - const mulistreamTitle = (target?: MultistreamTargetViewHydrated) => { 29 + const multistreamTitle = ( 30 + target: MultistreamTargetViewHydrated | undefined, 31 + t: TFunction, 32 + ) => { 37 33 if (!target) { 38 - return "Untitled Target"; 34 + return t("untitled-multistream-target"); 39 35 } 40 36 if (target.record.name) { 41 37 return target.record.name; ··· 45 41 const u = new URL(target.record.url); 46 42 return u.host; 47 43 } catch (error) { 48 - return "Untitled Target"; 44 + return t("untitled-multistream-target"); 49 45 } 50 46 } 51 - return "Untitled Target"; 47 + return t("untitled-multistream-target"); 52 48 }; 53 49 54 50 export default function MultistreamManager() { 51 + const { theme } = zero.useTheme(); 52 + const { t } = useTranslation("settings"); 55 53 const agent = usePDSAgent(); 56 54 const [loading, setLoading] = useState(true); 57 55 const [targets, setTargets] = useState< ··· 86 84 setTargets(targetViews.data.targets as MultistreamTargetViewHydrated[]); 87 85 } catch (error) { 88 86 console.error("Failed to load multistream targets:", error); 89 - Alert.alert( 90 - "Error", 91 - "Failed to load multistream targets. Please try again.", 92 - ); 87 + Alert.alert("Error", t("failed-load-multistream-targets")); 93 88 } finally { 94 89 setLoading(false); 95 90 } ··· 158 153 await loadMultistreamTargets(); 159 154 } catch (error) { 160 155 console.error("Failed to toggle multistream target:", error); 161 - Alert.alert( 162 - "Error", 163 - "Failed to toggle multistream target. Please try again.", 164 - ); 156 + Alert.alert("Error", t("failed-toggle-multistream-target")); 165 157 } finally { 166 158 setTogglingTargets((prev) => { 167 159 const newSet = new Set(prev); ··· 209 201 }; 210 202 211 203 return ( 212 - <ThemeProvider> 213 - <View style={[flex.values[1]]}> 214 - <ScrollView style={[flex.values[1]]}> 215 - <View style={[{ maxWidth: 800 }, mx.auto]}> 204 + <> 205 + <ScrollView> 206 + <View style={[zero.layout.flex.align.center, zero.px[2], zero.py[2]]}> 207 + <View style={{ maxWidth: 800, width: "100%" }}> 216 208 {/* Header */} 217 209 <View style={[mb[6]]}> 218 210 <Text style={[mb[2], { fontSize: 24, fontWeight: "700" }]}> 219 - Multistream Targets 211 + {t("multistream-targets")} 220 212 </Text> 221 213 <Text style={[text.gray[400], mb[4], { fontSize: 14 }]}> 222 - Automatically push your Streamplace livestreams to other 223 - streaming services like Twitch or YouTube. 214 + {t("multistream-description")} 224 215 </Text> 225 - <View style={[layout.flex.row, gap.all[3]]}> 226 - <Button onPress={handleCreate} size="sm" leftIcon={<Plus />}> 227 - <Text>Create Multistream Target</Text> 216 + 217 + <View 218 + style={[ 219 + layout.flex.row, 220 + layout.flex.justify.start, 221 + gap.all[3], 222 + w.percent[100], 223 + mt[2], 224 + ]} 225 + > 226 + <Button 227 + onPress={handleCreate} 228 + size="pill" 229 + width="min" 230 + leftIcon={<Plus color={theme.colors.text} />} 231 + > 232 + <Text>{t("create-multistream-target")}</Text> 228 233 </Button> 229 234 230 235 <Button 231 236 onPress={loadMultistreamTargets} 232 237 disabled={loading} 233 - leftIcon={<RefreshCw />} 234 - size="sm" 238 + leftIcon={<RefreshCw color={theme.colors.text} />} 239 + size="pill" 240 + width="min" 241 + variant="secondary" 235 242 > 236 - <Text>Refresh</Text> 243 + <Text>{t("refresh")}</Text> 237 244 </Button> 238 245 </View> 239 246 </View> ··· 245 252 ) : targets === null ? ( 246 253 <View style={[layout.flex.center, mt[8]]}> 247 254 <Text style={[text.gray[600]]}> 248 - Failed to load multistream targets 255 + {t("failed-load-multistream-targets")} 249 256 </Text> 250 257 </View> 251 258 ) : targets.length === 0 ? ( 252 259 <View style={[layout.flex.center, mt[8]]}> 253 260 <Text style={[text.gray[600], mb[4], { fontSize: 16 }]}> 254 - No targets yet! 261 + {t("no-multistream-targets-yet")} 255 262 </Text> 256 263 </View> 257 264 ) : ( 258 265 <> 259 266 <View style={[mb[4]]}> 260 267 <Text style={[text.gray[600], { fontSize: 14 }]}> 261 - {targets.length} target{targets.length !== 1 && "s"} 268 + {t("multistream-targets-count", { count: targets.length })} 262 269 </Text> 263 270 </View> 264 271 {targets.map((target) => ( ··· 280 287 ))} 281 288 </> 282 289 )} 283 - </ScrollView> 284 - <MultistreamTargetForm 285 - target={editingTarget} 286 - isVisible={showForm} 287 - onClose={() => { 288 - setShowForm(false); 289 - }} 290 - onSubmit={(record: PlaceStreamMultistreamTarget.Record) => { 291 - if (editingTarget) { 292 - editMultistreamTarget(editingTarget.uri, record); 293 - } else { 294 - createMultistreamTarget(record); 295 - } 296 - }} 297 - isLoading={formLoading} 298 - formError={formError} 299 - /> 300 - 301 - <MultistreamTargetDeleteDialog 302 - target={deleteDialog.target || undefined} 303 - isVisible={deleteDialog.isVisible} 304 - onClose={() => 305 - setDeleteDialog({ 306 - isVisible: false, 307 - target: null, 308 - isLoading: false, 309 - }) 290 + </View> 291 + </ScrollView> 292 + <MultistreamTargetForm 293 + target={editingTarget} 294 + isVisible={showForm} 295 + onClose={() => { 296 + setShowForm(false); 297 + }} 298 + onSubmit={(record: PlaceStreamMultistreamTarget.Record) => { 299 + if (editingTarget) { 300 + editMultistreamTarget(editingTarget.uri, record); 301 + } else { 302 + createMultistreamTarget(record); 310 303 } 311 - onSubmit={() => 312 - deleteDialog.target && 313 - deleteMultistreamTarget(deleteDialog.target.uri) 314 - } 315 - isLoading={deleteDialog.isLoading} 316 - formError={formError} 317 - /> 318 - </View> 319 - </ThemeProvider> 304 + }} 305 + isLoading={formLoading} 306 + formError={formError} 307 + /> 308 + 309 + <MultistreamTargetDeleteDialog 310 + target={deleteDialog.target || undefined} 311 + isVisible={deleteDialog.isVisible} 312 + onClose={() => 313 + setDeleteDialog({ 314 + isVisible: false, 315 + target: null, 316 + isLoading: false, 317 + }) 318 + } 319 + onSubmit={() => 320 + deleteDialog.target && 321 + deleteMultistreamTarget(deleteDialog.target.uri) 322 + } 323 + isLoading={deleteDialog.isLoading} 324 + formError={formError} 325 + /> 326 + </> 320 327 ); 321 328 } 322 329 ··· 335 342 isDeleting: boolean; 336 343 isToggling: boolean; 337 344 }) { 345 + const { t } = useTranslation("settings"); 338 346 // Determine latest event status for footer 339 347 const getStatusInfo = () => { 340 348 if (target.latestEvent) { 341 349 return ( 342 350 <View style={[layout.flex.row, gap.all[4]]}> 343 351 <Text style={[text.gray[400], { fontSize: 11 }]}> 344 - Status: {target.latestEvent.status} 352 + {t("status")}: {target.latestEvent.status} 345 353 </Text> 346 354 <Text style={[text.gray[400], { fontSize: 11 }]}> 347 355 {timeAgo(new Date(target.latestEvent.createdAt))} ··· 354 362 355 363 return ( 356 364 <SettingsListItem 357 - title={mulistreamTitle(target)} 365 + title={multistreamTitle(target, t)} 358 366 url={target.record.url} 359 367 active={target.record.active} 360 368 isDeleting={isDeleting} 361 369 isToggling={isToggling} 362 370 footer={{ 363 - left: `Created ${timeAgo(new Date(target.record.createdAt))}`, 371 + left: `${t("created")} ${timeAgo(new Date(target.record.createdAt))}`, 364 372 right: getStatusInfo(), 365 373 }} 366 374 onEdit={() => onEdit(target)} ··· 385 393 isLoading: boolean; 386 394 formError: string; 387 395 }) { 396 + const { t } = useTranslation("settings"); 388 397 const [formData, setFormData] = useState<PlaceStreamMultistreamTarget.Record>( 389 398 { 390 399 $type: "place.stream.multistream.target", ··· 443 452 <Dialog 444 453 open={isVisible} 445 454 onOpenChange={(open) => !open && onClose()} 446 - title={target ? "Edit Target" : "Create Target"} 455 + title={ 456 + target ? t("multistream-edit-target") : t("multistream-create-target") 457 + } 447 458 size="lg" 448 459 dismissible={false} 449 460 > ··· 453 464 <Text 454 465 style={[text.gray[300], mb[2], { fontSize: 14, fontWeight: "500" }]} 455 466 > 456 - Name (optional) 467 + {t("rtmp-target-name")} ({t("optional")}) 457 468 </Text> 458 469 <Input 459 470 value={formData.name} 460 471 onChangeText={(text) => 461 472 setFormData((prev) => ({ ...prev, name: text })) 462 473 } 463 - placeholder="My Multistream Target" 474 + placeholder={t("rtmp-target-name-placeholder")} 464 475 /> 465 476 </View> 466 477 ··· 469 480 <Text 470 481 style={[text.gray[300], mb[2], { fontSize: 14, fontWeight: "500" }]} 471 482 > 472 - Webhook URL * 483 + {t("rtmp-target-url")} * 473 484 </Text> 474 485 <Input 475 486 value={formData.url} ··· 497 508 ]} 498 509 > 499 510 <Text style={[text.gray[300], { fontSize: 14, fontWeight: "500" }]}> 500 - Active 511 + {t("active")} 501 512 </Text> 502 513 <Switch 503 514 value={formData.active} ··· 512 523 </View> 513 524 514 525 <DialogFooter> 515 - <Button variant="secondary" onPress={onClose} disabled={isLoading}> 526 + <Button 527 + variant="secondary" 528 + onPress={onClose} 529 + disabled={isLoading} 530 + width="min" 531 + > 516 532 <Text>Cancel</Text> 517 533 </Button> 518 - <Button onPress={handleSubmit} disabled={isLoading}> 534 + <Button onPress={handleSubmit} disabled={isLoading} width="min"> 519 535 <Text>{isLoading ? "Saving..." : target ? "Update" : "Create"}</Text> 520 536 </Button> 521 537 </DialogFooter> ··· 538 554 isLoading: boolean; 539 555 formError: string; 540 556 }) => { 557 + const { t } = useTranslation("settings"); 541 558 return ( 542 559 <Dialog 543 560 open={isVisible} ··· 547 564 > 548 565 <View style={[w.percent[100], mb[8], mt[2]]}> 549 566 <Text style={[{ fontSize: 24 }]}> 550 - Are you sure you want to delete "{mulistreamTitle(target)}"? 567 + {t("multistream-delete-target-confirmation", { 568 + target: multistreamTitle(target, t), 569 + })} 551 570 </Text> 552 571 <Text 553 572 style={[text.gray[400], mt[4], { fontSize: 18, fontWeight: "700" }]} 554 573 > 555 - This action cannot be undone. 556 - </Text> 557 - <Text style={[text.gray[400], { fontSize: 18, fontWeight: "700" }]}> 558 - The webhook will no longer receive events. 574 + {t("this-action-cannot-be-undone")} 559 575 </Text> 560 576 </View> 561 577 ··· 573 589 </Button> 574 590 <Button variant="destructive" onPress={onSubmit} disabled={isLoading}> 575 591 <Text style={[text.white, { fontSize: 14, fontWeight: "500" }]}> 576 - {isLoading ? "Deleting..." : "Delete"} 592 + {isLoading ? t("deleting") : t("delete")} 577 593 </Text> 578 594 </Button> 579 595 </View>
+7 -1
js/app/components/settings/streaming-category-settings.tsx
··· 5 5 View, 6 6 zero, 7 7 } from "@streamplace/components"; 8 - import { Heart, Key, Webhook } from "lucide-react-native"; 8 + import { Globe, Heart, Key, Webhook } from "lucide-react-native"; 9 9 import { useTranslation } from "react-i18next"; 10 10 import { ScrollView } from "react-native"; 11 11 import { SettingsNavigationItem } from "./components/settings-navigation-item"; ··· 34 34 title={t("webhooks")} 35 35 screen="WebhooksSettings" 36 36 icon={Webhook} 37 + /> 38 + <MenuSeparator /> 39 + <SettingsNavigationItem 40 + title={t("multistream")} 41 + screen="MultistreamCategory" 42 + icon={Globe} 37 43 /> 38 44 </MenuGroup> 39 45 </MenuContainer>
+7 -2
js/app/components/settings/webhook-manager.tsx
··· 594 594 </View> 595 595 596 596 <DialogFooter> 597 - <Button variant="secondary" onPress={onClose} disabled={isLoading}> 597 + <Button 598 + width="min" 599 + variant="secondary" 600 + onPress={onClose} 601 + disabled={isLoading} 602 + > 598 603 <Text>{t("cancel")}</Text> 599 604 </Button> 600 - <Button onPress={handleSubmit} disabled={isLoading}> 605 + <Button width="min" onPress={handleSubmit} disabled={isLoading}> 601 606 <Text> 602 607 {isLoading ? t("saving") : webhook ? t("update") : t("create")} 603 608 </Text>
+10
js/app/src/router.tsx
··· 73 73 74 74 import { useUrl } from "@streamplace/components"; 75 75 import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; 76 + import MultistreamManager from "components/settings/multistream-manager"; 76 77 import RecommendationsManager from "components/settings/recommendations-manager"; 77 78 import Constants from "expo-constants"; 78 79 import { useBlueskyNotifications } from "hooks/useBlueskyNotifications"; ··· 123 124 LanguagesCategory: undefined; 124 125 DeveloperSettings: undefined; 125 126 KeyManagement: undefined; 127 + MultistreamCategory: undefined; 126 128 }; 127 129 128 130 type RootStackParamList = { ··· 178 180 DanmuCategory: "settings/danmu", 179 181 AdvancedCategory: "settings/advanced", 180 182 DeveloperSettings: "settings/developer", 183 + MultistreamCategory: "settings/streaming/multistream", 184 + KeyManagement: "settings/streaming/key-management", 185 + LanguagesCategory: "settings/languages", 181 186 }, 182 187 }, 183 188 KeyManagement: "key-management", ··· 801 806 name="KeyManagement" 802 807 component={KeyManager} 803 808 options={{ headerTitle: "Key Manager", title: "Key Manager" }} 809 + /> 810 + <Stack.Screen 811 + name="MultistreamCategory" 812 + component={MultistreamManager} 813 + options={{ headerTitle: "Multistream", title: "Multistream" }} 804 814 /> 805 815 </Stack.Navigator> 806 816 );
+26
js/components/locales/en-US/settings.ftl
··· 62 62 sign-in = Sign In 63 63 update = Update 64 64 log-out = Log out 65 + optional = optional 65 66 66 67 ## Account Settings 67 68 account-greeting = Hey, @{ $handle }. ··· 117 118 events-chat = Chat Events 118 119 untitled-webhook = Untitled Webhook 119 120 inactive = Inactive 121 + active = Active 122 + 123 + ## Multistreaming 124 + multistreaming = Multistreaming 125 + multistream-targets = Multistream Targets 126 + multistream-description = Automatically push your Streamplace livestreams to other streaming services like Twitch or YouTube. 127 + create-multistream-target = Create Multistream Target 128 + untitled-multistream-target = Untitled Target 129 + failed-load-multistream-targets = Failed to load multistream targets. Please try again. 130 + failed-toggle-multistream-target = Failed to toggle multistream target. Please try again. 131 + failed-delete-multistream-target = Failed to delete multistream target. Please try again. 132 + no-multistream-targets-yet = No targets yet! 133 + multistream-targets-count = { $count -> 134 + [one] { $count } target 135 + *[other] { $count } targets 136 + } 137 + multistream-delete-target-confirmation = Are you sure you want to delete "{ $target }"? 138 + this-action-cannot-be-undone = This action cannot be undone. 139 + rtmp-target-name = RTMP Target 140 + rtmp-target-url = RTMP URL 141 + rtmp-target-name-placeholder = My Multistream Target 142 + multistream-create-target = Create Target 143 + multistream-edit-target = Edit Target 144 + created = created 145 + status = status 120 146 121 147 ## Debug Recording 122 148 debug-recording = Debug Recording
+82
pkg/director/stream_session.go
··· 109 109 110 110 close(ss.started) 111 111 112 + ss.Go(ctx, func() error { 113 + return ss.HandleMultistreamTargets(ctx) 114 + }) 115 + 112 116 for { 113 117 select { 114 118 case <-ss.segmentChan: ··· 679 683 680 684 return client, nil 681 685 } 686 + 687 + type runningMultistream struct { 688 + cancel func() 689 + uri string 690 + } 691 + 692 + // we're making an attempt here not to log (sensitive) stream keys, so we're 693 + // referencing by atproto URI 694 + func (ss *StreamSession) HandleMultistreamTargets(ctx context.Context) error { 695 + ctx = log.WithLogValues(ctx, "system", "multistreaming") 696 + isTrue := true 697 + // {target.Uri}:{rec.Url} -> runningMultistream 698 + // no concurrency issues, it's only used from this one loop 699 + running := map[string]*runningMultistream{} 700 + for { 701 + targets, err := ss.statefulDB.ListMultistreamTargets(ss.repoDID, 100, 0, &isTrue) 702 + if err != nil { 703 + return fmt.Errorf("failed to list multistream targets: %w", err) 704 + } 705 + currentRunning := map[string]bool{} 706 + for _, targetView := range targets { 707 + rec, ok := targetView.Record.Val.(*streamplace.MultistreamTarget) 708 + if !ok { 709 + log.Error(ctx, "failed to convert multistream target to streamplace multistream target", "uri", targetView.Uri) 710 + continue 711 + } 712 + key := fmt.Sprintf("%s:%s", targetView.Uri, rec.Url) 713 + if running[key] == nil { 714 + childCtx, childCancel := context.WithCancel(ctx) 715 + ss.Go(ctx, func() error { 716 + log.Log(ctx, "starting multistream target", "uri", targetView.Uri) 717 + err := ss.statefulDB.CreateMultistreamEvent(targetView.Uri, "starting multistream target", "pending") 718 + if err != nil { 719 + log.Error(ctx, "failed to create multistream event", "error", err) 720 + } 721 + return ss.StartMultistreamTarget(childCtx, targetView) 722 + }) 723 + running[key] = &runningMultistream{ 724 + cancel: childCancel, 725 + uri: key, 726 + } 727 + } 728 + currentRunning[key] = true 729 + } 730 + for key := range running { 731 + if !currentRunning[key] { 732 + log.Log(ctx, "stopping multistream target", "uri", running[key].uri) 733 + running[key].cancel() 734 + delete(running, key) 735 + } 736 + } 737 + select { 738 + case <-ctx.Done(): 739 + return nil 740 + case <-time.After(time.Second * 5): 741 + continue 742 + } 743 + } 744 + } 745 + 746 + func (ss *StreamSession) StartMultistreamTarget(ctx context.Context, targetView *streamplace.MultistreamDefs_TargetView) error { 747 + for { 748 + err := ss.mm.RTMPPush(ctx, ss.repoDID, "source", targetView) 749 + if err != nil { 750 + log.Error(ctx, "failed to push to RTMP server", "error", err) 751 + err := ss.statefulDB.CreateMultistreamEvent(targetView.Uri, err.Error(), "error") 752 + if err != nil { 753 + log.Error(ctx, "failed to create multistream event", "error", err) 754 + } 755 + } 756 + select { 757 + case <-ctx.Done(): 758 + return nil 759 + case <-time.After(time.Second * 5): 760 + continue 761 + } 762 + } 763 + }
-3
pkg/media/rtmp_push.go
··· 7 7 "io" 8 8 "net" 9 9 "net/url" 10 - "reflect" 11 10 "strings" 12 11 "time" 13 12 ··· 18 17 "stream.place/streamplace/pkg/streamplace" 19 18 ) 20 19 21 - // This function remains in scope for the duration of a single users' playback 22 20 func (mm *MediaManager) RTMPPush(ctx context.Context, user string, rendition string, targetView *streamplace.MultistreamDefs_TargetView) error { 23 21 uu, err := uuid.NewV7() 24 22 if err != nil { ··· 90 88 log.Error(ctx, "failed to get rtmp2sink peak-kbps", "prop", prop) 91 89 continue 92 90 } 93 - log.Warn(ctx, "rtmp2sink peak-kbps", "prop", reflect.TypeOf(prop)) 94 91 propVal, ok := prop.(*gst.Structure) 95 92 if !ok { 96 93 log.Error(ctx, "failed to convert rtmp2sink peak-kbps", "prop", prop)