From 6b19efcbb54f3427d3289f52870887b75dda2df0 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Fri, 14 Aug 2026 13:33:43 -0500 Subject: [PATCH 01/76] fix link in docs Signed-off-by: Natalie Bridgers --- js/docs/src/content/docs/developers.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/docs/src/content/docs/developers.mdx b/js/docs/src/content/docs/developers.mdx index 272070d05..bf98ad928 100644 --- a/js/docs/src/content/docs/developers.mdx +++ b/js/docs/src/content/docs/developers.mdx @@ -21,7 +21,7 @@ import { Card, CardGrid } from "@astrojs/starlight/components"; Run your own Streamplace infrastructure. - [Installation - guide](/docs/guides/installing/installing-streamplace) + guide](/docs/guides/installing/downloading-streamplace) {" "} -- 2.54.0 From ec33d544f2b79c5d74ec560413738fcb6fd4ff0b Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Fri, 14 Aug 2026 14:08:25 -0500 Subject: [PATCH 02/76] pin node version to supported so it builds right when usign workers (lol) Signed-off-by: Natalie Bridgers --- .nvmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 -- 2.54.0 From 7c4148c60979754b8edb2ceb688eeaf3478984c3 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Tue, 18 Aug 2026 20:54:45 -0500 Subject: [PATCH 03/76] i hate CI Signed-off-by: Natalie Bridgers --- js/docs/.nvmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 js/docs/.nvmrc diff --git a/js/docs/.nvmrc b/js/docs/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/js/docs/.nvmrc @@ -0,0 +1 @@ +22 -- 2.54.0 From b6070de0247d2a813f86b389488046986dee0f0f Mon Sep 17 00:00:00 2001 From: Joshua <1041261+JoshuaACasey@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:59:11 -0500 Subject: [PATCH 04/76] fix missing streamplace-logo.svg in README.md --- streamplace-logo.svg | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 streamplace-logo.svg diff --git a/streamplace-logo.svg b/streamplace-logo.svg new file mode 100644 index 000000000..1b829f028 --- /dev/null +++ b/streamplace-logo.svg @@ -0,0 +1,17 @@ + + + + + -- 2.54.0 From e41b01651b76380241f6f36627dc500d262b23a0 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 8 Oct 2026 16:31:53 -0700 Subject: [PATCH 05/76] deps: bump muxl to carry unknown track groups as opaque tracks Pins github.com/streamplace/muxl/go to streamplace/muxl#7 (dbacdfe). muxl readers now carry canonical segments of track groups they don't decode (e.g. upcoming transcript/text tracks) through unwrap, events, metafiles and flat wraps instead of failing with "segment catalog describes no track"; only moov synthesis and the appendable fMP4 leave them out. Event bytes still reassemble to the stored blob, so the VOD metafile's byte-range offsets stay correct. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 230d9dc5d..d3b3f3e71 100644 --- a/go.mod +++ b/go.mod @@ -73,7 +73,7 @@ require ( github.com/streamplace/atmoq/go v0.0.4-0.20260701223355-13757de4ae08 github.com/streamplace/atproto-oauth-golang v0.0.0-20260413212710-98956064d06c github.com/streamplace/glex v0.0.0-20260820164827-814f46540f22 - github.com/streamplace/muxl/go v0.3.5 + github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512 github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b github.com/stretchr/testify v1.11.1 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 diff --git a/go.sum b/go.sum index fa86cf1c4..3b8402224 100644 --- a/go.sum +++ b/go.sum @@ -1397,8 +1397,8 @@ github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAy github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 h1:e8M3qPLr37NxEjlr18TaAwGP+OVyherVjgUG5VVmgWI= github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0= -github.com/streamplace/muxl/go v0.3.5 h1:sp33xrcblpl1nfKVXy03sDfQd7rM2aizbIaYajUH2zg= -github.com/streamplace/muxl/go v0.3.5/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8= +github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512 h1:Xd5iaNxwHXGOSOHJkC/X0eyo8r4Pz/NDB8WOjislZbo= +github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8= github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b h1:eWbwCtBbMyrDTHLYIold07OR2hmvzXsbAUxi57ElMLk= github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b/go.mod h1:wpY+T/wE00jrUhgh2dKXbbE91D36u86KGlENK/hWFkE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -- 2.54.0 From c3868dc5184d550a0fcb639e9c7c230c177501ca Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Fri, 9 Oct 2026 14:28:11 -0700 Subject: [PATCH 06/76] deps: pin muxl to v0.3.6 release Replaces the streamplace/muxl#7 pseudo-version with the v0.3.6 tag, which contains #7 (opaque tracks for unknown track groups). --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d3b3f3e71..2db94e391 100644 --- a/go.mod +++ b/go.mod @@ -73,7 +73,7 @@ require ( github.com/streamplace/atmoq/go v0.0.4-0.20260701223355-13757de4ae08 github.com/streamplace/atproto-oauth-golang v0.0.0-20260413212710-98956064d06c github.com/streamplace/glex v0.0.0-20260820164827-814f46540f22 - github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512 + github.com/streamplace/muxl/go v0.3.6 github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b github.com/stretchr/testify v1.11.1 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 diff --git a/go.sum b/go.sum index 3b8402224..b87120386 100644 --- a/go.sum +++ b/go.sum @@ -1397,8 +1397,8 @@ github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAy github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 h1:e8M3qPLr37NxEjlr18TaAwGP+OVyherVjgUG5VVmgWI= github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0= -github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512 h1:Xd5iaNxwHXGOSOHJkC/X0eyo8r4Pz/NDB8WOjislZbo= -github.com/streamplace/muxl/go v0.3.6-0.20261009003714-dbacdfe9e512/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8= +github.com/streamplace/muxl/go v0.3.6 h1:lyWnY15nft/0GK3LBA4OkD2aHrLV9RRqI+YcDRPFk3g= +github.com/streamplace/muxl/go v0.3.6/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8= github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b h1:eWbwCtBbMyrDTHLYIold07OR2hmvzXsbAUxi57ElMLk= github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b/go.mod h1:wpY+T/wE00jrUhgh2dKXbbE91D36u86KGlENK/hWFkE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -- 2.54.0 From e324ef759146352685d689efee671c4008b7d2de Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Fri, 9 Oct 2026 20:42:19 -0700 Subject: [PATCH 07/76] app: signed-out About link in the top-level sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit About only lived under Settings -> About, which nobody signed out would think to open. The sidebar now carries an About entry pointing at the same page (/settings/about), shown only while signed out; signed in it stays in the settings menu with the rest of the account chrome. Drops the orphaned marketing About screen that the top-level /about path rendered — nothing linked to it, and /settings/about is the About page now. The sidebar also stops lighting up a prefix-matching entry (Settings spans /settings/*) when a sibling entry matches the current path exactly, so only About highlights on /settings/about. Web e2e covers both states (flows/about-link.spec.ts). --- js/app/components/sidebar/sidebar-overlay.tsx | 26 ++- js/app/src/linking-config.ts | 2 - js/app/src/navigation-helper.tsx | 1 - js/app/src/navigation-types.ts | 1 - js/app/src/screens/about.tsx | 181 ------------------ js/app/src/shell.tsx | 9 - js/e2e-web/flows/about-link.spec.ts | 48 +++++ 7 files changed, 71 insertions(+), 197 deletions(-) delete mode 100644 js/app/src/screens/about.tsx create mode 100644 js/e2e-web/flows/about-link.spec.ts diff --git a/js/app/components/sidebar/sidebar-overlay.tsx b/js/app/components/sidebar/sidebar-overlay.tsx index 1df4febd0..d62ef452e 100644 --- a/js/app/components/sidebar/sidebar-overlay.tsx +++ b/js/app/components/sidebar/sidebar-overlay.tsx @@ -29,6 +29,7 @@ import { Download, Hash, Home, + Info, Library, Link as LinkIcon, List, @@ -494,6 +495,15 @@ export function SidebarOverlay() { // the sidebar — this row is just the logged-out "Log in" entry. const secondaryItems: SidebarNavItem[] = [ { icon: LogIn, label: "Log in", href: "/login", hidden: !!did }, + { + icon: Info, + // Signed out, About gets a top-level entry instead of hiding two levels + // deep under Settings; signed in it stays in the settings menu, where the + // rest of the account chrome lives. + label: "About", + href: "/settings/about", + hidden: !!did, + }, { icon: SettingsIcon, label: "Settings", @@ -553,8 +563,13 @@ export function SidebarOverlay() { ); - const renderItems = (items: SidebarNavItem[]) => - items.map((item) => { + const renderItems = (items: SidebarNavItem[]) => { + // A prefix match (Settings spans /settings/*) yields when a sibling entry + // matches the current path exactly, so only the page you're on lights up. + const onExactEntry = items.some( + (item) => !item.hidden && item.href === currentPath, + ); + return items.map((item) => { if (item.hidden) return null; return ( { e.preventDefault(); @@ -571,6 +590,7 @@ export function SidebarOverlay() { /> ); }); + }; return ( - - - - - - {title} - - - {body} - - - - ); -} - -export default function AboutScreen() { - const { theme } = useTheme(); - const c = theme.colors; - return ( - - - {/* Hero */} - - - - - What is Streamplace? - - - The video layer for the open social web. - - - Open-source infrastructure bringing high-quality, creator-owned - video to the AT Protocol — designed around user sovereignty and - content authenticity. - - - - - {/* Feature list */} - - {FEATURES.map((f) => ( - - ))} - - - {/* Get-involved CTA */} - - - Want to get involved? - - - Join our Discord to learn more about Streamplace and how you can - help build it. - - - - - - - - ); -} diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx index 791eed444..3fa4b9aeb 100644 --- a/js/app/src/shell.tsx +++ b/js/app/src/shell.tsx @@ -57,7 +57,6 @@ import { Platform, Pressable, StatusBar, View } from "react-native"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { SFSymbols7_0 } from "sf-symbols-typescript"; import "src/navigation-types"; -import AboutScreen from "src/screens/about"; import AppReturnScreen from "src/screens/app-return"; import PopoutChat from "src/screens/chat-popout"; import DanmuOBSScreen from "src/screens/danmu-obs"; @@ -222,14 +221,6 @@ function HomeNavigator() { }), }} /> - About once you are signed in. +const HTTPS_URL = process.env.SERVER_HTTPS_URL; + +test("about-link: signed out, the sidebar links straight to the About page", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("home-stream-card").first()).toBeVisible({ + timeout: 30_000, + }); + + await page.getByRole("link", { name: "About", exact: true }).first().click(); + await expect(page).toHaveURL(/\/settings\/about$/); + await expect(page.getByText(/^Streamplace v/).first()).toBeVisible(); +}); + +test("about-link: signed in, About stays in the settings menu", async ({ + page, +}) => { + test.skip(!HTTPS_URL, "harness started without its HTTPS hostnames"); + await loginThroughPds(page); + + await page.goto(`${HTTPS_URL}/`); + await expect(page.getByTestId("home-stream-card").first()).toBeVisible({ + timeout: 30_000, + }); + // the sidebar drops "Log in" once the session lands, so it is showing the + // signed-in list by the time the absence check runs + await expect( + page.getByRole("link", { name: "Log in", exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole("link", { name: "About", exact: true }), + ).toHaveCount(0); + + await page + .getByRole("link", { name: "Settings", exact: true }) + .first() + .click(); + await expect(page.getByText("Advanced").first()).toBeVisible(); + await page.getByText("About", { exact: true }).first().click(); + await expect(page).toHaveURL(/\/settings\/about$/); +}); -- 2.54.0 From 96156d7e4c43eeee944381eba3a3a16d89e7349e Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Fri, 9 Oct 2026 21:02:25 -0700 Subject: [PATCH 08/76] sidebar: mark the current row with aria-current so the highlight is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile flagged that the exact-match highlight rule had no coverage — the active pill was the only cue, and it is invisible to assistive tech as well as to tests. Expose it as aria-current=page and assert both directions in the signed-out about-link flow: About (not Settings) is current on /settings/about, Settings is current on /settings/advanced. --- js/app/components/sidebar/sidebar-item.tsx | 3 ++ js/e2e-web/flows/about-link.spec.ts | 45 +++++++++++++++------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/js/app/components/sidebar/sidebar-item.tsx b/js/app/components/sidebar/sidebar-item.tsx index dd3044323..303512f7e 100644 --- a/js/app/components/sidebar/sidebar-item.tsx +++ b/js/app/components/sidebar/sidebar-item.tsx @@ -74,6 +74,9 @@ export default function SidebarItem({ onHoverIn={() => setHover(true)} onHoverOut={() => setHover(false)} role="link" + // The current destination. The filled pill is the only other cue, which + // assistive tech (and e2e) can't see. + aria-current={active ? "page" : undefined} accessibilityLabel={typeof label === "string" ? label : "Link to " + href} // @ts-ignore This makes it render as on web! href={href} diff --git a/js/e2e-web/flows/about-link.spec.ts b/js/e2e-web/flows/about-link.spec.ts index 57a686b42..e7aa82e9a 100644 --- a/js/e2e-web/flows/about-link.spec.ts +++ b/js/e2e-web/flows/about-link.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { loginThroughPds } from "./login"; // The About entry is a top-level sidebar destination while signed out — the @@ -6,6 +6,9 @@ import { loginThroughPds } from "./login"; // folds back into Settings > About once you are signed in. const HTTPS_URL = process.env.SERVER_HTTPS_URL; +const sidebarLink = (page: Page, name: string) => + page.getByRole("link", { name, exact: true }).first(); + test("about-link: signed out, the sidebar links straight to the About page", async ({ page, }) => { @@ -14,9 +17,32 @@ test("about-link: signed out, the sidebar links straight to the About page", asy timeout: 30_000, }); - await page.getByRole("link", { name: "About", exact: true }).first().click(); + await sidebarLink(page, "About").click(); await expect(page).toHaveURL(/\/settings\/about$/); await expect(page.getByText(/^Streamplace v/).first()).toBeVisible(); + + // Only the destination you're on lights up, though Settings matches every + // /settings/* path by prefix. + await expect(sidebarLink(page, "About")).toHaveAttribute( + "aria-current", + "page", + ); + await expect(sidebarLink(page, "Settings")).not.toHaveAttribute( + "aria-current", + "page", + ); + + // ...and that prefix still carries every other settings page. + await page.goto("/settings/advanced"); + await expect(page.getByText("Advanced").first()).toBeVisible(); + await expect(sidebarLink(page, "Settings")).toHaveAttribute( + "aria-current", + "page", + ); + await expect(sidebarLink(page, "About")).not.toHaveAttribute( + "aria-current", + "page", + ); }); test("about-link: signed in, About stays in the settings menu", async ({ @@ -31,17 +57,10 @@ test("about-link: signed in, About stays in the settings menu", async ({ }); // the sidebar drops "Log in" once the session lands, so it is showing the // signed-in list by the time the absence check runs - await expect( - page.getByRole("link", { name: "Log in", exact: true }), - ).toHaveCount(0); - await expect( - page.getByRole("link", { name: "About", exact: true }), - ).toHaveCount(0); - - await page - .getByRole("link", { name: "Settings", exact: true }) - .first() - .click(); + await expect(sidebarLink(page, "Log in")).toHaveCount(0); + await expect(sidebarLink(page, "About")).toHaveCount(0); + + await sidebarLink(page, "Settings").click(); await expect(page.getByText("Advanced").first()).toBeVisible(); await page.getByText("About", { exact: true }).first().click(); await expect(page).toHaveURL(/\/settings\/about$/); -- 2.54.0 From 702aae979c367ea5842e02e68790fc08ec2cccdb Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 11 Oct 2026 15:50:31 -0700 Subject: [PATCH 09/76] fix shadow input idle accounting and paced EOF fixture --- pkg/api/duplicate_mist.go | 73 ++++++++++++++++++++++++++-------- pkg/api/duplicate_mist_test.go | 52 ++++++++++++++++++++++++ pkg/cmd/duplicate_mist_test.go | 52 +++++++++++++++++------- pkg/rtmps/shadow.go | 18 +++++---- 4 files changed, 157 insertions(+), 38 deletions(-) diff --git a/pkg/api/duplicate_mist.go b/pkg/api/duplicate_mist.go index df0349a0e..ce71a02d9 100644 --- a/pkg/api/duplicate_mist.go +++ b/pkg/api/duplicate_mist.go @@ -7,7 +7,7 @@ import ( "fmt" "io" "net" - "sync/atomic" + "sync" "time" "github.com/bluenviron/gortmplib" @@ -24,22 +24,68 @@ func (replayRTMP) Write(p []byte) (int, error) { return len(p), nil } var errShadowIdleTimeout = errors.New("shadow RTMP input idle timeout") +// Only a pending input Read is idle. Parsing buffered bytes, initializing native +// media, and draining after EOF must not consume the publisher's idle allowance. type shadowIdleReader struct { io.Reader - timer *time.Timer + mu sync.Mutex + timer *time.Timer + deadline time.Time + timedOut bool + cancel context.CancelFunc } -func (r shadowIdleReader) Read(p []byte) (int, error) { +func newShadowIdleReader(input io.Reader, cancel context.CancelFunc) *shadowIdleReader { + r := &shadowIdleReader{Reader: input, cancel: cancel} + r.timer = time.AfterFunc(RTMPTimeout, r.expire) + r.timer.Stop() + return r +} + +func (r *shadowIdleReader) expire() { + r.mu.Lock() + defer r.mu.Unlock() + if r.deadline.IsZero() { + return + } + if remaining := time.Until(r.deadline); remaining > 0 { + // Stop cannot retract a callback already queued by an earlier Read. + // That callback must honor the current Read's full idle allowance. + r.timer.Reset(remaining) + return + } + r.timedOut = true + r.cancel() +} + +func (r *shadowIdleReader) Read(p []byte) (int, error) { + r.mu.Lock() + if r.timedOut { + r.mu.Unlock() + return 0, errShadowIdleTimeout + } + r.deadline = time.Now().Add(RTMPTimeout) + r.timer.Reset(RTMPTimeout) + r.mu.Unlock() n, err := r.Reader.Read(p) - if err != nil { - // Publisher EOF starts downstream drain, not another input deadline. - r.timer.Stop() - } else if n > 0 { - r.timer.Reset(RTMPTimeout) + r.mu.Lock() + r.deadline = time.Time{} + r.timer.Stop() + if r.timedOut { + err = errShadowIdleTimeout } + r.mu.Unlock() return n, err } +func (r *shadowIdleReader) stop() bool { + r.mu.Lock() + defer r.mu.Unlock() + r.deadline = time.Time{} + r.timer.Stop() + return r.timedOut +} + // RunDuplicateMistWorker consumes the decrypted, client-only RTMP conversation. // gortmplib's non-strict plain handshake consumes C2 without comparing it to // the shadow's S1, so the publisher's response to Mist's handshake is valid. @@ -57,19 +103,14 @@ func RunDuplicateMistWorker(parent context.Context, input io.Reader, progress io } }) defer stopInput() - var timedOut atomic.Bool - idle := time.AfterFunc(RTMPTimeout, func() { - timedOut.Store(true) - cancel() - }) + inputReader := newShadowIdleReader(input, cancel) defer func() { - idle.Stop() - if timedOut.Load() { + if inputReader.stop() { retErr = errShadowIdleTimeout } }() - br := bufio.NewReader(shadowIdleReader{Reader: input, timer: idle}) + br := bufio.NewReader(inputReader) version, err := br.Peek(1) if err != nil { return fmt.Errorf("shadow RTMP handshake: %w", err) diff --git a/pkg/api/duplicate_mist_test.go b/pkg/api/duplicate_mist_test.go index df228d27b..18c45b102 100644 --- a/pkg/api/duplicate_mist_test.go +++ b/pkg/api/duplicate_mist_test.go @@ -2,14 +2,66 @@ package api import ( "bytes" + "context" "fmt" "io" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/require" ) +func TestShadowIdleReaderAllowsConsumerWork(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reader := newShadowIdleReader(bytes.NewReader([]byte{3}), cancel) + defer reader.stop() + var buf [1]byte + n, err := reader.Read(buf[:]) + require.NoError(t, err) + require.Equal(t, 1, n) + + // A caller processing bytes is not waiting on idle publisher input. + time.Sleep(2 * RTMPTimeout) + require.NoError(t, ctx.Err()) + n, err = reader.Read(buf[:]) + require.ErrorIs(t, err, io.EOF) + require.Zero(t, n) + time.Sleep(2 * RTMPTimeout) + require.NoError(t, ctx.Err(), "publisher EOF must allow downstream drain") + }) +} + +func TestShadowIdleReaderStaleCallbackHonorsPendingRead(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + input, publisher := io.Pipe() + defer input.Close() + defer publisher.Close() + stopInput := context.AfterFunc(ctx, func() { _ = input.Close() }) + defer stopInput() + reader := newShadowIdleReader(input, cancel) + defer reader.stop() + result := make(chan error, 1) + go func() { + var buf [1]byte + _, err := reader.Read(buf[:]) + result <- err + }() + synctest.Wait() + time.Sleep(RTMPTimeout / 2) + // A previous timer callback can already be queued when a new Read starts. + reader.expire() + require.NoError(t, ctx.Err(), "an old callback must not shorten this Read") + time.Sleep(RTMPTimeout / 2) + require.ErrorIs(t, <-result, errShadowIdleTimeout) + require.ErrorIs(t, ctx.Err(), context.Canceled) + }) +} + func TestDuplicateMistIdleHandshakeClosesOnlyShadowInput(t *testing.T) { input, publisher := io.Pipe() defer input.Close() diff --git a/pkg/cmd/duplicate_mist_test.go b/pkg/cmd/duplicate_mist_test.go index f45657477..31e90548e 100644 --- a/pkg/cmd/duplicate_mist_test.go +++ b/pkg/cmd/duplicate_mist_test.go @@ -19,6 +19,8 @@ import ( "time" "github.com/bluenviron/gortmplib" + "github.com/bluenviron/gortmplib/pkg/bytecounter" + "github.com/bluenviron/gortmplib/pkg/message" "github.com/bluenviron/gortsplib/v5/pkg/format" "github.com/go-gst/go-gst/gst" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -251,16 +253,36 @@ func TestDuplicateMistEndToEnd(t *testing.T) { stop := context.AfterFunc(ctx, func() { client.Close(); server.Close() }) defer stop() replies := make(chan struct{}) - go func() { _, _ = io.Copy(client, server); client.Close(); close(replies) }() + go func() { + _, _ = io.Copy(client, server) + client.Close() + // The encoder can finish before the TLS publisher's EOF gate opens. + _, _ = io.Copy(io.Discard, server) + close(replies) + }() forwarded, err := io.Copy(server, client) if err == nil { - // Keep the actual TLS publisher connected until the parent has - // observed live verification, even if encoding already reached EOS. - select { - case <-publisherEOF: - case <-ctx.Done(): - err = ctx.Err() - } + // Keep the actual TLS publisher active until live verification, + // even when encoding reaches EOS before a slow worker catches up. + counter := bytecounter.NewWriter(server) + keepalive := message.NewWriter(counter, counter, false) + err = func() error { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-publisherEOF: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if err := keepalive.Write(&message.UserControlPingRequest{}); err != nil { + return err + } + } + } + }() + forwarded += int64(counter.Count()) } if corrupt && err == nil { // Invalid RTMP after verified media must produce a failure. @@ -296,6 +318,13 @@ func TestDuplicateMistEndToEnd(t *testing.T) { busDone := make(chan error, 1) go func() { busDone <- media.HandleBusMessages(ctx, pipeline) }() require.NoError(t, pipeline.SetState(gst.StatePlaying)) + select { + case err := <-busDone: + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal("publisher timed out") + } + require.NoError(t, pipeline.SetState(gst.StateNull)) if paced { // The TLS publisher has not sent EOF; progress cannot be deferred // until worker completion. Encoding speed is irrelevant. @@ -305,13 +334,6 @@ func TestDuplicateMistEndToEnd(t *testing.T) { } close(publisherEOF) select { - case err := <-busDone: - require.NoError(t, err) - case <-ctx.Done(): - t.Fatal("publisher timed out") - } - require.NoError(t, pipeline.SetState(gst.StateNull)) - select { case err := <-primaryResult: require.NoError(t, err) case <-ctx.Done(): diff --git a/pkg/rtmps/shadow.go b/pkg/rtmps/shadow.go index 05e73570b..7121185e0 100644 --- a/pkg/rtmps/shadow.go +++ b/pkg/rtmps/shadow.go @@ -69,10 +69,12 @@ publisher identifiers or stream keys; use the failure logs for details. The shadow has an 8 MiB per-connection buffer and a cap of 16 workers per addon listener, including draining workers. At capacity, new connections -forward only to Mist and log a shadow failure. Shadow input idle for 10 -seconds is aborted without closing the primary connection. Buffer overflow -also kills only the shadow; Mist continues unchanged. On disconnect it -drains the native pipeline through EOS and verifies the final segment. +forward only to Mist and log a shadow failure. A pending shadow input read +idle for 10 seconds is aborted without closing the primary connection. +Parsing buffered input, native initialization, and signing do not consume +this idle allowance. Buffer overflow also kills only the shadow; Mist +continues unchanged. On disconnect it drains the native pipeline through +EOS and verifies the final segment. The playback handshake deadline is cleared for this drain; the 30-second worker-exit deadline still applies. Node shutdown kills/reaps workers. This is opt-in diagnostic work with additional CPU and memory cost, up to @@ -84,9 +86,11 @@ The backend end-to-end regression is `TestDuplicateMistEndToEnd` in through verified segments beyond the former few-second failure interval, including a publisher outside `/live/` and all final segments after paced and unpaced publisher EOF. -The fixture holds TLS publisher EOF until live metrics show verification, -so slow worker startup is not confused with a metrics failure. Corrupt RTMP -must export a failure while subsequent primary traffic still arrives. +The fixture stops the encoder at EOS but holds TLS publisher EOF until live +metrics show verification, sending RTMP keepalives while it waits. Slow +worker startup or signing is not confused with idle publisher input. +Corrupt RTMP must export a failure while subsequent primary traffic still +arrives. No client UI or platform-specific iOS/Android/Web behavior changes. */ -- 2.54.0 From c85e04317f39f03319b35558ebecbe2c6662c440 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 11 Oct 2026 15:53:30 -0700 Subject: [PATCH 10/76] chat: keep long handles inside the web chat column A handle wider than the chat column stretched the compact web row past the list. The chat list is a vertical ScrollView, which react-native-web renders with overflow-x: hidden, so the colon and the message body were clipped at the right edge with no way to scroll to them. The trigger box around the handle is a View inside a Text, i.e. an inline-level flex box, so its shrink-to-fit width followed its max-content, and the handle group inside it could not shrink at all (react-native-web's View default is flexShrink 0). - Cap the trigger box at maxWidth 100%; its containing block is the message body, which has a definite width. - Let the handle group and the message body shrink below their min-content width (flexShrink/minWidth 0). - Clamp the handle to one line (numberOfLines) so it ellipsizes, with the colon still riding at its end. An unbreakable word in the message body now breaks mid-word instead of widening the row the same way. js/e2e-web/flows/chat-long-handle.spec.ts covers both: it seeds a second PDS account whose handle is one long label (the PDS caps labels at 18 characters under its service domain) and asserts the chat list has no horizontal overflow, that the handle ellipsizes with the colon still after it, and that an unbreakable body word stays inside the column. Both flows fail on the unfixed tree. --- .../src/components/chat/chat-message.tsx | 34 ++- .../src/components/chat/user-profile-card.tsx | 6 + js/e2e-web/flows/chat-long-handle.spec.ts | 229 ++++++++++++++++++ 3 files changed, 257 insertions(+), 12 deletions(-) create mode 100644 js/e2e-web/flows/chat-long-handle.spec.ts diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index 599d8a415..25e7996dc 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -205,33 +205,43 @@ const MessageBodyWeb = ({ item }: { item: ChatMessageViewHydrated }) => { // so words fill the space after the handle and wrap naturally, the // way an unselectable row did. selectableText opts the flow back into web // text selection. - + // + // A handle wider than the row used to stretch the row past the chat + // column, whose scroll container hides horizontal overflow, putting the + // colon and the message out of reach. The handle clamps to one line and + // ellipsizes instead: minWidth 0 lets the group (react-native-web's View + // default is flexShrink 0) and this shrink below their min-content + // width, and the trigger box around the handle is capped at 100% in + // UserProfileCard. + {formatHandleWithAt(item.author)} - {/* The colon rides in the handle's inline box so the pair wraps as - one unit instead of the colon dropping to the next line. */} + {/* The colon rides in the handle's box so the pair stays one unit on + one line instead of the colon dropping to the next line. */} {":"} diff --git a/js/components/src/components/chat/user-profile-card.tsx b/js/components/src/components/chat/user-profile-card.tsx index f2bf3e5f9..58c27aac5 100644 --- a/js/components/src/components/chat/user-profile-card.tsx +++ b/js/components/src/components/chat/user-profile-card.tsx @@ -464,6 +464,12 @@ export const UserProfileCard = ({ marginLeft: -3, marginRight: -2, marginBottom: -4, + // A View inside a Text is an inline-level flex box, so its + // shrink-to-fit width is driven by its max-content unless it is + // capped here. The compact chat row's handle lives in this box, so + // without the cap a handle longer than the row would size this box + // past the chat column and take the message with it. + maxWidth: "100%", ...(hovered ? { backgroundColor: theme.colors.surfaceHover } : {}), }, ]} diff --git a/js/e2e-web/flows/chat-long-handle.spec.ts b/js/e2e-web/flows/chat-long-handle.spec.ts new file mode 100644 index 000000000..6e24e1c2b --- /dev/null +++ b/js/e2e-web/flows/chat-long-handle.spec.ts @@ -0,0 +1,229 @@ +import { + expect, + request, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; + +// The web chat row renders the handle, the colon and the message as one inline +// flow. The trigger box around the handle could not shrink, so it stayed at its +// max-content width: a handle longer than the chat column stretched the row past +// the list, and the scroll container's overflow-x: hidden (react-native-web +// hides it for a vertical ScrollView) put the rest of the message, colon and +// all, out of reach with no way to scroll to it. The handle is now a single-line +// item that ellipsizes, and the body can shrink past its min-content width, so +// no row can exceed the column. +// +// Chat is filled by writing records straight to the account's PDS, so these +// flows need no browser login. The harness account's own handle is short, so the +// long handle needs its own author: the local PDS accepts a second account +// because handles under its service domain need no DNS. +const SERVER_URL = process.env.SERVER_URL; +const PDS_URL = process.env.PDS_HTTPS_URL; +const HANDLE = process.env.ACCOUNT_HANDLE!; +const DID = process.env.ACCOUNT_DID!; +const PASSWORD = process.env.ACCOUNT_PASSWORD!; + +test.skip(!PDS_URL, "harness started without its HTTPS hostnames"); + +// The PDS allows one label of at most 18 characters under the service domain, +// so a handle this long is as long as the harness can make one. +const LONG_LABEL = "verylonghandlehere"; + +async function postChatMessage( + pds: APIRequestContext, + jwt: string, + repo: string, + text: string, +) { + const res = await pds.post(`${PDS_URL}/xrpc/com.atproto.repo.createRecord`, { + headers: { Authorization: `Bearer ${jwt}` }, + data: { + repo, + collection: "place.stream.chat.message", + record: { + $type: "place.stream.chat.message", + text, + createdAt: new Date().toISOString(), + streamer: DID, + }, + }, + }); + expect(res.ok(), `createRecord: ${res.status()} ${await res.text()}`).toBe( + true, + ); + const { uri } = await res.json(); + return { rkey: String(uri).split("/").pop()! }; +} + +async function deleteChatMessage( + pds: APIRequestContext, + jwt: string, + repo: string, + rkey: string, +) { + const res = await pds.post(`${PDS_URL}/xrpc/com.atproto.repo.deleteRecord`, { + headers: { Authorization: `Bearer ${jwt}` }, + data: { repo, collection: "place.stream.chat.message", rkey }, + }); + expect(res.ok(), `deleteRecord: ${res.status()} ${await res.text()}`).toBe( + true, + ); +} + +// An account whose handle is one long, unbroken label, with one chat message +// posted from it. +// +// The harness node and its chat list last for the whole run, and the other chat +// flows locate rows by the harness account's handle, so a message from another +// author has to be deleted again when this flow is done. +async function seedLongHandleMessage(text: string) { + // the PDS's throwaway CA is trusted by the harness's browsers, not by Node + const pds = await request.newContext({ ignoreHTTPSErrors: true }); + const handle = `${LONG_LABEL}.${new URL(PDS_URL!).hostname}`; + const created = await pds.post( + `${PDS_URL}/xrpc/com.atproto.server.createAccount`, + { + data: { handle, email: `${LONG_LABEL}@example.com`, password: PASSWORD }, + }, + ); + expect( + created.ok(), + `createAccount: ${created.status()} ${await created.text()}`, + ).toBe(true); + const { did, accessJwt } = await created.json(); + const { rkey } = await postChatMessage(pds, accessJwt, did, text); + return { + pds, + handle, + cleanup: () => deleteChatMessage(pds, accessJwt, did, rkey), + }; +} + +async function seedOwnMessage(text: string) { + const pds = await request.newContext({ ignoreHTTPSErrors: true }); + const session = await pds.post( + `${PDS_URL}/xrpc/com.atproto.server.createSession`, + { data: { identifier: HANDLE, password: PASSWORD } }, + ); + expect(session.ok(), `createSession: ${session.status()}`).toBe(true); + const { accessJwt } = await session.json(); + await postChatMessage(pds, accessJwt, DID, text); + return pds; +} + +async function openStreamChat(page: Page, text: string) { + await page.goto(`${SERVER_URL}/`); + await page.getByTestId("home-stream-card").first().click(); + await expect(page.getByText(text, { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + return page.getByTestId("chat-list").first(); +} + +// The chat list clips horizontal overflow, so any row wider than it is +// unreachable. Every row must fit. +async function expectNoHorizontalOverflow(page: Page) { + const { scrollWidth, clientWidth } = await page + .getByTestId("chat-list") + .first() + .evaluate((el) => ({ + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth, + })); + expect( + scrollWidth, + "chat rows stay inside the chat column", + ).toBeLessThanOrEqual(clientWidth + 1); +} + +test("chat-long-handle: a long handle ellipsizes instead of pushing the message out of view", async ({ + page, +}) => { + const message = `long handle body ${Date.now()}`; + const author = await seedLongHandleMessage(message); + + try { + const list = await openStreamChat(page, message); + await expectNoHorizontalOverflow(page); + + // The chat list carries every message seeded earlier in the run, so scope + // to this author's own row. + const trigger = list + .getByTestId(`chat-profile-trigger-${author.handle}`) + .first(); + const handle = trigger.getByText(`@${author.handle}`, { exact: true }); + const colon = trigger.getByText(":", { exact: true }); + const body = list.getByText(message, { exact: true }).first(); + const [handleBox, colonBox, bodyBox, listBox] = await Promise.all([ + handle.boundingBox(), + colon.boundingBox(), + body.boundingBox(), + list.boundingBox(), + ]); + expect(handleBox).not.toBeNull(); + expect(colonBox).not.toBeNull(); + expect(bodyBox).not.toBeNull(); + expect(listBox).not.toBeNull(); + + // The handle is clamped to one line with an ellipsis: it is wider than the + // box it is drawn in, and that box stays inside the column. + expect( + await handle.evaluate((el) => getComputedStyle(el).textOverflow), + "the handle ellipsizes", + ).toBe("ellipsis"); + expect( + await handle.evaluate((el) => el.scrollWidth > el.clientWidth), + "the long handle is wider than its clamped box", + ).toBe(true); + expect(handleBox!.x + handleBox!.width).toBeLessThanOrEqual( + listBox!.x + listBox!.width + 1, + ); + + // The colon still rides at the end of the handle, on the handle's line, and + // the message body is still visible inside the column. + expect( + Math.abs(colonBox!.y - handleBox!.y), + "colon shares the handle's line", + ).toBeLessThan(handleBox!.height); + expect(colonBox!.x).toBeGreaterThanOrEqual( + handleBox!.x + handleBox!.width - 2, + ); + expect(bodyBox!.x + bodyBox!.width).toBeLessThanOrEqual( + listBox!.x + listBox!.width + 1, + ); + } finally { + await author.cleanup(); + await author.pds.dispose(); + } +}); + +test("chat-long-handle: an unbreakable word in the body stays inside the column", async ({ + page, +}) => { + // Longer on its own than the chat column, like a long URL. + const token = "supercalifragilisticexpialidociousandthensomemoreletters"; + const message = `body with ${token} inside`; + const pds = await seedOwnMessage(message); + + const list = await openStreamChat(page, message); + await expectNoHorizontalOverflow(page); + + // The word wraps mid-token rather than running past the column: every line + // box of the message ends inside the list. + const listBox = (await list.boundingBox())!; + const listRight = listBox.x + listBox.width; + const lineRights = await list + .getByText(message, { exact: true }) + .first() + .evaluate((el) => + Array.from(el.getClientRects()).map((rect) => rect.x + rect.width), + ); + expect(lineRights.length).toBeGreaterThan(1); + for (const right of lineRights) { + expect(right).toBeLessThanOrEqual(listRight + 1); + } + + await pds.dispose(); +}); -- 2.54.0 From 5ec3e588de34f5f49549ccf830c60f730bd824d0 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 11 Oct 2026 16:23:18 -0700 Subject: [PATCH 11/76] chat-long-handle: give each attempt its own seeded author The flow registers its long-handle author on the harness PDS, and a retry reuses that PDS: the account from the first attempt was still registered, so the retry failed on `Handle already taken` before it reached the browser. Derive the label from the clock, which stays inside the PDS's 18-character service-domain cap and still outgrows the chat column. Verified with `hack/e2e-web-local.sh chat-long-handle.spec.ts --repeat-each=2` against one harness: all four runs pass. --- js/e2e-web/flows/chat-long-handle.spec.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/js/e2e-web/flows/chat-long-handle.spec.ts b/js/e2e-web/flows/chat-long-handle.spec.ts index 6e24e1c2b..f14ea8f91 100644 --- a/js/e2e-web/flows/chat-long-handle.spec.ts +++ b/js/e2e-web/flows/chat-long-handle.spec.ts @@ -27,9 +27,13 @@ const PASSWORD = process.env.ACCOUNT_PASSWORD!; test.skip(!PDS_URL, "harness started without its HTTPS hostnames"); -// The PDS allows one label of at most 18 characters under the service domain, -// so a handle this long is as long as the harness can make one. -const LONG_LABEL = "verylonghandlehere"; +// The account's label under the PDS's service domain. It has to be long enough +// that the handle outgrows the chat column, and unique per attempt: a retry +// runs against the same PDS, where the account the previous attempt created is +// still registered. The PDS caps a service-domain label at 18 characters. +function longHandleLabel(): string { + return `longhandle${Date.now().toString(36).slice(-6)}`; +} async function postChatMessage( pds: APIRequestContext, @@ -81,11 +85,12 @@ async function deleteChatMessage( async function seedLongHandleMessage(text: string) { // the PDS's throwaway CA is trusted by the harness's browsers, not by Node const pds = await request.newContext({ ignoreHTTPSErrors: true }); - const handle = `${LONG_LABEL}.${new URL(PDS_URL!).hostname}`; + const label = longHandleLabel(); + const handle = `${label}.${new URL(PDS_URL!).hostname}`; const created = await pds.post( `${PDS_URL}/xrpc/com.atproto.server.createAccount`, { - data: { handle, email: `${LONG_LABEL}@example.com`, password: PASSWORD }, + data: { handle, email: `${label}@example.com`, password: PASSWORD }, }, ); expect( -- 2.54.0 From 65ea65f894642ad0e23bd28d54933fedf8e92afd Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 10:07:05 -0700 Subject: [PATCH 12/76] fix media sample ownership in concat writer --- docs/go-conventions.md | 8 ++++++++ pkg/media/io_helpers.go | 6 ++++++ pkg/media/io_helpers_test.go | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/docs/go-conventions.md b/docs/go-conventions.md index 70ee02e72..cb5d81a82 100644 --- a/docs/go-conventions.md +++ b/docs/go-conventions.md @@ -97,3 +97,11 @@ contexts before returning. The leak helper waits for finalizer passes before requesting the native snapshot; keep that barrier synchronous and never block the finalizer queue, which also releases Go-owned GStreamer references. Verify changes to the helper with race-enabled media tests without disabling leak assertions. + +`WriterNewSample` releases each appsink sample when its callback returns, after +unmapping its buffer. The go-gst v1.4 `GstSample` wrapper contains only a pointer +to an opaque C type; on amd64 it is an 8-byte, GC-pointer-free allocation. Go's +tiny allocator can pack it beside another live object, preventing its finalizer +from running even after repeated GC. Clear the binding's finalizer before +explicitly releasing that transferred reference, or it may be released twice. +This is sample ownership, not a requirement to wrap every `Buffer.Map` call. diff --git a/pkg/media/io_helpers.go b/pkg/media/io_helpers.go index 886f58f97..357827c06 100644 --- a/pkg/media/io_helpers.go +++ b/pkg/media/io_helpers.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "runtime" "github.com/go-gst/go-gst/gst" "github.com/go-gst/go-gst/gst/app" @@ -86,6 +87,11 @@ func WriterNewSample(ctx context.Context, output io.Writer) func(sink *app.Sink) if sample == nil { return gst.FlowOK } + // GstSample is an opaque C type, so this small wrapper can share a + // Go tiny-allocation block with a live object and never be finalized. + // Release our owned reference when the callback finishes instead. + runtime.SetFinalizer(sample, nil) + defer sample.Unref() // Retrieve the buffer from the sample. buffer := sample.GetBuffer() diff --git a/pkg/media/io_helpers_test.go b/pkg/media/io_helpers_test.go index 027fda13c..4b5aac004 100644 --- a/pkg/media/io_helpers_test.go +++ b/pkg/media/io_helpers_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "runtime" "testing" "github.com/go-gst/go-gst/gst" @@ -42,6 +43,28 @@ func TestWriterNewSample(t *testing.T) { }) } +// A live tiny allocation can pin a neighboring GstSample wrapper indefinitely. +// Keep the writer's markers alive through the native leak check, not just EOS. +func TestWriterNewSampleTinyAllocations(t *testing.T) { + writer := &tinyAllocWriter{} + withNoGSTLeaks(t, func() { + require.NoError(t, writerNewSampleInner(context.Background(), 0, writer)) + }) + runtime.KeepAlive(writer) +} + +type tinyAllocWriter struct { + bytes.Buffer + markers []*uint64 +} + +func (w *tinyAllocWriter) Write(p []byte) (int, error) { + marker := new(uint64) + *marker = uint64(len(p)) + w.markers = append(w.markers, marker) + return w.Buffer.Write(p) +} + func writerNewSampleInner(ctx context.Context, i int, w io.Writer) error { ctx = log.WithLogValues(ctx, "func", "TestWriterNewSample") ctx, cancel := context.WithCancel(ctx) -- 2.54.0 From 156ea94cb1b2b6ba92350904a88b4aad2a4ac6e8 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 14:11:22 -0700 Subject: [PATCH 13/76] golive: ignore canonicalURLs from old handles --- .../components/live-dashboard/livestream-panel.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index 89bd8d9e1..96016e664 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -63,6 +63,14 @@ const { flex, p, px, py, gap, layout, bg, borders, text, r, w, typography } = const isWeb = Platform.OS === "web"; +function isSameNodeUrl(candidate: string, nodeUrl: string): boolean { + try { + return new URL(candidate).origin === new URL(nodeUrl).origin; + } catch { + return false; + } +} + export const LANG_TAG_PREFIX = "lang:"; export const LANGUAGES = [ @@ -368,7 +376,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { if ( livestream.record.canonicalUrl && - livestream.record.canonicalUrl !== defaultCanonicalUrl + !isSameNodeUrl(livestream.record.canonicalUrl, url) ) { setCanonicalUrl(livestream.record.canonicalUrl); } @@ -384,7 +392,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { setCreatePost(typeof livestream.record.post !== "undefined"); } - }, [livestream, defaultCanonicalUrl]); + }, [livestream, url]); const handleModeChange = useCallback( (newMode: "create" | "metadata" | "moderation") => { @@ -791,6 +799,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { onChange={(value) => setCanonicalUrl(value)} placeholder={defaultCanonicalUrl} variant="filled" + testID="live-canonical-url" inputStyle={[ p[3], r.md, -- 2.54.0 From 6c33747df29c228848850c26a56ebd4783e63b8b Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Fri, 9 Oct 2026 16:19:40 -0700 Subject: [PATCH 14/76] Split secondary app screens and optional playback dependencies --- .maestro/logged-out/lazy-navigation.yaml | 36 +++ js/app/README.md | 34 +++ js/app/package.json | 2 + js/app/src/lazy-screen.tsx | 27 ++ js/app/src/router.tsx | 2 +- js/app/src/screens/home.tsx | 7 +- js/app/src/shell.tsx | 167 ++++++++--- js/app/store/slices/blueskySlice.ts | 3 +- .../src/components/mobile-player/video.tsx | 68 +++-- .../src/livestream-store/stream-key.tsx | 3 +- js/e2e-web/flows/lazy-navigation.spec.ts | 64 ++++ pnpm-lock.yaml | 281 +++++------------- 12 files changed, 419 insertions(+), 275 deletions(-) create mode 100644 .maestro/logged-out/lazy-navigation.yaml create mode 100644 js/app/src/lazy-screen.tsx create mode 100644 js/e2e-web/flows/lazy-navigation.spec.ts diff --git a/.maestro/logged-out/lazy-navigation.yaml b/.maestro/logged-out/lazy-navigation.yaml new file mode 100644 index 000000000..6d9bdabeb --- /dev/null +++ b/.maestro/logged-out/lazy-navigation.yaml @@ -0,0 +1,36 @@ +appId: ${APP_ID} +--- +# iOS and Android keep dynamic imports in their installed bundle. Exercise +# first-use category loading and cold deep links, not web-only chunk requests. +- launchApp +- openLink: ${APP_ID}:// +- extendedWaitUntil: + visible: + id: "home-stream-card" + timeout: 30000 +- tapOn: + text: "Settings" +- extendedWaitUntil: + visible: + text: "Advanced" + timeout: 30000 +- tapOn: + text: "Advanced" +- extendedWaitUntil: + visible: + id: "settings-use-custom-node" + timeout: 30000 +- takeScreenshot: lazy-navigation-advanced +- stopApp +- openLink: ${APP_ID}://settings/advanced +- extendedWaitUntil: + visible: + id: "settings-use-custom-node" + timeout: 30000 +- takeScreenshot: lazy-navigation-deep-link +- tapOn: + text: "Home" +- extendedWaitUntil: + visible: + id: "home-stream-card" + timeout: 30000 diff --git a/js/app/README.md b/js/app/README.md index 0d965c369..ef38c1636 100644 --- a/js/app/README.md +++ b/js/app/README.md @@ -38,6 +38,40 @@ Only when native code changes: Then run `pnpm app ios` (or `pnpm app android`). Bump `runtimeVersion` in `package.json` only when native dependencies change (it gates expo-updates). +## Web bundle splitting and Atlas + +Secondary screens use module-scope `lazyScreen` imports with a screen-local +Suspense boundary. Keep the home feed and navigation shell eager; use direct +imports instead of barrels that re-export deferred screens. The broadcaster +front door also loads its stream/video screen lazily. HLS playback and stream-key +generation load their libraries only when needed. + +Expo splits production **web** exports. iOS/Android still include these modules +in the installed bundle; the same loading boundaries work without downloading +web chunks. + +Run the production analyzer from the builder container: + +```sh +pnpm app analyze:web +``` + +Open the URL printed by Expo Atlas (in a browser that can reach the container). +The export is in `.expo/atlas-web`, leaving the embedded `dist` bundle untouched. +Atlas's module graph includes deferred modules too: measure initial download size +by summing **all** scripts referenced by `.expo/atlas-web/index.html`, including +Expo's common/runtime chunks, rather than just the entrypoint. Check chunk source +maps and browser network requests to confirm heavy modules remain deferred. + +`.expo/atlas.jsonl` contains source code and inlined public environment variables; +keep it local. Rebuild with `make app` and `make dev` before exercising embedded +UI with `hack/e2e-web-local.sh`. + +`lazy-navigation.spec.ts` checks deferred settings requests, navigation away from +a pending chunk, and a cold settings deep link. The platform-neutral +`.maestro/logged-out/lazy-navigation.yaml` covers first-use navigation and deep +links on iOS/Android. + ## i18n FTL strings live in `../i18n/locales` and are compiled before use. Edits won't diff --git a/js/app/package.json b/js/app/package.json index d00f55aa4..aea2f3ee7 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -19,6 +19,7 @@ "build": "pnpm run build:web && pnpm run prebuild", "build:web": "pnpm run brand && node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json", "export": "expo export --dump-sourcemap || expo export --dump-sourcemap", + "analyze:web": "EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && expo-atlas --no-open", "check": "pnpm --filter @streamplace/i18n compile && bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'", "prebuild": "pnpm run brand && EXPO_NO_GIT_STATUS=1 expo prebuild --clean && sed -i.bak 's/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m/' android/gradle.properties && pnpm run find-node", "prepare": "which pod && pnpm run prepare-ios || echo 'not a mac, not installing pods' && mkdir -p ./public && cp -r ../i18n/public/locales ./public/", @@ -149,6 +150,7 @@ "@types/sdp-transform": "^2.15.0", "@types/uuid": "^10.0.0", "emojibase-data": "^17.0.0", + "expo-atlas": "^0.4.3", "i18next-parser": "^9.0.1", "typescript": "~5.9.3" }, diff --git a/js/app/src/lazy-screen.tsx b/js/app/src/lazy-screen.tsx new file mode 100644 index 000000000..5479ba7e3 --- /dev/null +++ b/js/app/src/lazy-screen.tsx @@ -0,0 +1,27 @@ +import { Loader, zero } from "@streamplace/components"; +import type { ComponentProps, ComponentType } from "react"; +import { lazy, Suspense } from "react"; +import { View } from "react-native"; + +function ScreenFallback() { + return ( + + + + ); +} + +// Code-split a screen: its module loads on first render, behind a Suspense +// boundary local to the screen so the surrounding navigation stays usable. +export function lazyScreen>( + load: () => Promise<{ default: T }>, +) { + const Screen = lazy(load); + return function LazyScreen(props: ComponentProps) { + return ( + }> + + + ); + }; +} diff --git a/js/app/src/router.tsx b/js/app/src/router.tsx index 20ca8fa1b..235e99068 100644 --- a/js/app/src/router.tsx +++ b/js/app/src/router.tsx @@ -14,7 +14,7 @@ import { useTheme, } from "@streamplace/components"; import { statusColors } from "@streamplace/components/src/lib/theme/tokens"; -import { Provider } from "components"; +import Provider from "components/provider/provider"; import { ImageBackground } from "expo-image"; import { useLiveUser } from "hooks/useLiveUser"; import { diff --git a/js/app/src/screens/home.tsx b/js/app/src/screens/home.tsx index cafebcff1..ad07534f3 100644 --- a/js/app/src/screens/home.tsx +++ b/js/app/src/screens/home.tsx @@ -23,10 +23,13 @@ import { useFrontDoorReload } from "hooks/useDefaultStreamerReload"; import { useEffect, useState } from "react"; import { Platform, View, useWindowDimensions } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { lazyScreen } from "src/lazy-screen"; import { useStore } from "store"; import { place } from "streamplace"; -import MobileStream from "./mobile-stream"; -import VideoScreen from "./video"; + +// Only rendered when the node sets a default streamer/video for its front door. +const MobileStream = lazyScreen(() => import("./mobile-stream")); +const VideoScreen = lazyScreen(() => import("./video")); function getStreamActivity( record: place.stream.livestream.Main, diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx index 3fa4b9aeb..1fb377b34 100644 --- a/js/app/src/shell.tsx +++ b/js/app/src/shell.tsx @@ -20,29 +20,11 @@ import { zero, } from "@streamplace/components"; import { colors, spacing } from "@streamplace/components/src/lib/theme/tokens"; -import { Settings } from "components"; import { SiteTitleLockup, useNodeTitle } from "components/brand/logo"; import { LogoBrandMenu } from "components/brand/logo-brand-menu"; -import Login from "components/login/login"; import LoginModal from "components/login/login-modal"; import PdsHostSelectorModal from "components/login/pds-host-selector-modal"; import { MobileAppBanner } from "components/mobile-app-banner"; -import { AboutCategorySettings } from "components/settings/about-category-settings"; -import { AccountCategorySettings } from "components/settings/account-category-settings"; -import { AdvancedCategorySettings } from "components/settings/advanced-category-settings"; -import { BackupSettings } from "components/settings/backup-settings"; -import { BadgeIssuerPanel } from "components/settings/badge-issuer-panel"; -import { BadgeSelectionManager } from "components/settings/badge-selection-manager"; -import { BrandingAdmin } from "components/settings/branding-admin"; -import { DanmuCategorySettings } from "components/settings/danmu-category-settings"; -import KeyManager from "components/settings/key-manager"; -import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; -import MultistreamManager from "components/settings/multistream-manager"; -import { NotificationsCategorySettings } from "components/settings/notifications-category-settings"; -import { PrivacyCategorySettings } from "components/settings/privacy-category-settings"; -import RecommendationsManager from "components/settings/recommendations-manager"; -import { StreamingCategorySettings } from "components/settings/streaming-category-settings"; -import WebhookManager from "components/settings/webhook-manager"; import { SidebarOverlay, SidebarToggle, @@ -56,34 +38,9 @@ import { useEffect, useState } from "react"; import { Platform, Pressable, StatusBar, View } from "react-native"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { SFSymbols7_0 } from "sf-symbols-typescript"; +import { lazyScreen } from "src/lazy-screen"; import "src/navigation-types"; -import AppReturnScreen from "src/screens/app-return"; -import PopoutChat from "src/screens/chat-popout"; -import DanmuOBSScreen from "src/screens/danmu-obs"; -import DownloadScreen from "src/screens/download"; -import EmbedScreen from "src/screens/embed"; import HomeScreen from "src/screens/home"; -import InfoWidgetEmbed from "src/screens/info-widget-embed"; -import LaunchGoLive from "src/screens/launch-go-live"; -import LiveDashboard from "src/screens/live-dashboard"; -import MobileGoLive from "src/screens/mobile-go-live"; -import MobileStream from "src/screens/mobile-stream"; -import MultiScreen from "src/screens/multi"; -import PopoutInfoWidget from "src/screens/popout-info-widget"; -import PopoutLivestream from "src/screens/popout-livestream"; -import PopoutMultistream from "src/screens/popout-multistream"; -import PopoutStreamMonitor from "src/screens/popout-stream-monitor"; -import SupportScreen from "src/screens/support"; -import UploadScreen, { - UploadDraftsScreen, - UploadLivestreamsScreen, - UploadVideoScreen, - UploadVideosScreen, -} from "src/screens/upload"; -import VideoScreen from "src/screens/video"; -import VideoListScreen from "src/screens/video-list"; -import VodScreen from "src/screens/vod"; -import VodEmbedScreen from "src/screens/vod-embed"; import { useStore } from "store"; import { useHydrated, @@ -97,6 +54,128 @@ import { UploadButton, } from "./router"; +// Every screen except the home feed is code-split, so its module (and +// dependencies) only load when the route is first visited. +const Login = lazyScreen(() => import("components/login/login")); +const Settings = lazyScreen(() => + import("components/settings/settings").then((m) => ({ default: m.Settings })), +); +const AboutCategorySettings = lazyScreen(() => + import("components/settings/about-category-settings").then((m) => ({ + default: m.AboutCategorySettings, + })), +); +const AccountCategorySettings = lazyScreen(() => + import("components/settings/account-category-settings").then((m) => ({ + default: m.AccountCategorySettings, + })), +); +const AdvancedCategorySettings = lazyScreen(() => + import("components/settings/advanced-category-settings").then((m) => ({ + default: m.AdvancedCategorySettings, + })), +); +const BackupSettings = lazyScreen(() => + import("components/settings/backup-settings").then((m) => ({ + default: m.BackupSettings, + })), +); +const BadgeIssuerPanel = lazyScreen(() => + import("components/settings/badge-issuer-panel").then((m) => ({ + default: m.BadgeIssuerPanel, + })), +); +const BadgeSelectionManager = lazyScreen(() => + import("components/settings/badge-selection-manager").then((m) => ({ + default: m.BadgeSelectionManager, + })), +); +const BrandingAdmin = lazyScreen(() => + import("components/settings/branding-admin").then((m) => ({ + default: m.BrandingAdmin, + })), +); +const DanmuCategorySettings = lazyScreen(() => + import("components/settings/danmu-category-settings").then((m) => ({ + default: m.DanmuCategorySettings, + })), +); +const KeyManager = lazyScreen(() => import("components/settings/key-manager")); +const LanguagesCategorySettings = lazyScreen(() => + import("components/settings/languages-category-settings").then((m) => ({ + default: m.LanguagesCategorySettings, + })), +); +const MultistreamManager = lazyScreen( + () => import("components/settings/multistream-manager"), +); +const NotificationsCategorySettings = lazyScreen(() => + import("components/settings/notifications-category-settings").then((m) => ({ + default: m.NotificationsCategorySettings, + })), +); +const PrivacyCategorySettings = lazyScreen(() => + import("components/settings/privacy-category-settings").then((m) => ({ + default: m.PrivacyCategorySettings, + })), +); +const RecommendationsManager = lazyScreen( + () => import("components/settings/recommendations-manager"), +); +const StreamingCategorySettings = lazyScreen(() => + import("components/settings/streaming-category-settings").then((m) => ({ + default: m.StreamingCategorySettings, + })), +); +const WebhookManager = lazyScreen( + () => import("components/settings/webhook-manager"), +); +const AppReturnScreen = lazyScreen(() => import("src/screens/app-return")); +const PopoutChat = lazyScreen(() => import("src/screens/chat-popout")); +const DanmuOBSScreen = lazyScreen(() => import("src/screens/danmu-obs")); +const DownloadScreen = lazyScreen(() => import("src/screens/download")); +const EmbedScreen = lazyScreen(() => import("src/screens/embed")); +const InfoWidgetEmbed = lazyScreen( + () => import("src/screens/info-widget-embed"), +); +const LaunchGoLive = lazyScreen(() => import("src/screens/launch-go-live")); +const LiveDashboard = lazyScreen(() => import("src/screens/live-dashboard")); +const MobileGoLive = lazyScreen(() => import("src/screens/mobile-go-live")); +const MobileStream = lazyScreen(() => import("src/screens/mobile-stream")); +const MultiScreen = lazyScreen(() => import("src/screens/multi")); +const PopoutInfoWidget = lazyScreen( + () => import("src/screens/popout-info-widget"), +); +const PopoutLivestream = lazyScreen( + () => import("src/screens/popout-livestream"), +); +const PopoutMultistream = lazyScreen( + () => import("src/screens/popout-multistream"), +); +const PopoutStreamMonitor = lazyScreen( + () => import("src/screens/popout-stream-monitor"), +); +const SupportScreen = lazyScreen(() => import("src/screens/support")); +const UploadScreen = lazyScreen(() => import("src/screens/upload")); +const UploadVideoScreen = lazyScreen(() => + import("src/screens/upload").then((m) => ({ default: m.UploadVideoScreen })), +); +const UploadDraftsScreen = lazyScreen(() => + import("src/screens/upload").then((m) => ({ default: m.UploadDraftsScreen })), +); +const UploadLivestreamsScreen = lazyScreen(() => + import("src/screens/upload").then((m) => ({ + default: m.UploadLivestreamsScreen, + })), +); +const UploadVideosScreen = lazyScreen(() => + import("src/screens/upload").then((m) => ({ default: m.UploadVideosScreen })), +); +const VideoScreen = lazyScreen(() => import("src/screens/video")); +const VideoListScreen = lazyScreen(() => import("src/screens/video-list")); +const VodScreen = lazyScreen(() => import("src/screens/vod")); +const VodEmbedScreen = lazyScreen(() => import("src/screens/vod-embed")); + const AnimatedPressable = Animated.createAnimatedComponent(Pressable); const Tab = createBottomTabNavigator(); diff --git a/js/app/store/slices/blueskySlice.ts b/js/app/store/slices/blueskySlice.ts index 93cbcbf89..3427fcdaf 100644 --- a/js/app/store/slices/blueskySlice.ts +++ b/js/app/store/slices/blueskySlice.ts @@ -14,7 +14,6 @@ import { Platform } from "react-native"; import { AppStore } from "store"; import { place, StreamplaceAgent } from "streamplace"; import clearQueryParams from "utils/clear-query-params"; -import { privateKeyToAccount } from "viem/accounts"; import { StateCreator } from "zustand"; import createOAuthClient from "../../features/bluesky/oauthClient"; import { OAuthClient } from "../../features/bluesky/oauthClientImport"; @@ -639,6 +638,8 @@ export const createBlueskySlice: StateCreator< const hexKey = Array.from(exportedKey) .map((b) => b.toString(16).padStart(2, "0")) .join(""); + // Static import puts key-generation crypto in the initial web bundle. + const { privateKeyToAccount } = await import("viem/accounts"); const account = await privateKeyToAccount(`0x${hexKey}`); const newKey = { privateKey: multibaseKey, diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx index 2c325d906..f194330af 100644 --- a/js/components/src/components/mobile-player/video.tsx +++ b/js/components/src/components/mobile-player/video.tsx @@ -1,4 +1,4 @@ -import Hls from "hls.js"; +import type Hls from "hls.js"; import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; import { IngestMediaSource, @@ -375,11 +375,17 @@ export function HLSPlayer(props: VideoProps) { }, [setStatus]); useEffect(() => { - if (!localRef.current) { + const video = localRef.current; + if (!video) { return; } - if (Hls.isSupported()) { - var hls = new Hls({ + // hls.js is loaded on demand so it stays out of the main bundle. The + // effect may be cleaned up before the import resolves; `cancelled` + // stops a late setup, and `cleanup` tears down whatever was started. + let cancelled = false; + let cleanup: (() => void) | undefined; + const startHlsJs = (HlsClass: typeof Hls) => { + const hls = new HlsClass({ maxAudioFramesDrift: 20, // A `?t=` link starts a VOD partway in; hls.js clamps a position // past the end onto the last fragment. @@ -390,13 +396,15 @@ export function HLSPlayer(props: VideoProps) { hlsRef.current = hls; hls.loadSource(props.url); try { - hls.attachMedia(localRef.current); + hls.attachMedia(video); } catch (e) { - console.error("error on attachMedia"); - hls.stopLoad(); + console.error("error on attachMedia", e); + hls.destroy(); + hlsRef.current = null; + video.dispatchEvent(new Event("error")); return; } - hls.on(Hls.Events.MANIFEST_PARSED, () => { + hls.on(HlsClass.Events.MANIFEST_PARSED, () => { if (!localRef.current) { return; } @@ -416,7 +424,7 @@ export function HLSPlayer(props: VideoProps) { ); } }); - hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => { + hls.on(HlsClass.Events.LEVEL_SWITCHED, (_, data) => { const l = hls.levels[data.level]; if (!l) return; const name = @@ -429,9 +437,9 @@ export function HLSPlayer(props: VideoProps) { // there, so a viewer waiting on the page starts playing when it // begins — the same "pre-live" wait WebRTC gets from its reconnects. let retry: ReturnType | null = null; - hls.on(Hls.Events.ERROR, (_, data) => { + hls.on(HlsClass.Events.ERROR, (_, data) => { if (!data.fatal || hlsRef.current !== hls) return; - if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { + if (data.type === HlsClass.ErrorTypes.NETWORK_ERROR) { if (retry) clearTimeout(retry); retry = setTimeout(() => { retry = null; @@ -439,7 +447,7 @@ export function HLSPlayer(props: VideoProps) { hls.loadSource(props.url); hls.startLoad(); }, 2000); - } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + } else if (data.type === HlsClass.ErrorTypes.MEDIA_ERROR) { hls.recoverMediaError(); } }); @@ -451,10 +459,10 @@ export function HLSPlayer(props: VideoProps) { setVodLevels([]); setPlayingVODRendition(null); }; - } else if (localRef.current.canPlayType("application/vnd.apple.mpegurl")) { - // Native HLS (iPhone Safari): the element gives up on a 404 too, so - // re-point it at the playlist until it plays. - const video = localRef.current; + }; + // Native HLS (iPhone Safari): the element gives up on a 404 too, so + // re-point it at the playlist until it plays. + const startNative = () => { video.src = props.url; // Native HLS has no hls.js config to carry the start position, so // seek once the element knows how long the media is. @@ -485,7 +493,33 @@ export function HLSPlayer(props: VideoProps) { video.removeEventListener("canplay", onCanPlay); video.removeEventListener("error", onError); }; - } + }; + const canPlayNative = () => + video.canPlayType("application/vnd.apple.mpegurl") !== ""; + import("hls.js").then( + ({ default: HlsClass }) => { + if (cancelled) return; + if (HlsClass.isSupported()) { + cleanup = startHlsJs(HlsClass); + } else if (canPlayNative()) { + cleanup = startNative(); + } + }, + (e) => { + if (cancelled) return; + console.error("failed to load hls.js", e); + if (canPlayNative()) { + cleanup = startNative(); + } else { + // Report it the way the element reports its own errors. + video.dispatchEvent(new Event("error")); + } + }, + ); + return () => { + cancelled = true; + cleanup?.(); + }; }, [props.url, mode, startTime]); // The quality menu drives hls.js levels: "auto"/"source" leave ABR to diff --git a/js/components/src/livestream-store/stream-key.tsx b/js/components/src/livestream-store/stream-key.tsx index c6397f1c3..c3c761158 100644 --- a/js/components/src/livestream-store/stream-key.tsx +++ b/js/components/src/livestream-store/stream-key.tsx @@ -7,7 +7,6 @@ import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto"; import { getBrowserName, getPlatform } from "@streamplace/core"; import { useEffect, useState } from "react"; import { place } from "streamplace"; -import { privateKeyToAccount } from "viem/accounts"; import { usePDSAgent } from "../streamplace-store/xrpc"; import { useLivestreamStore } from "./use-store"; @@ -47,6 +46,8 @@ export const useStreamKey = (): { const hexKey = Array.from(exportedKey) .map((b) => b.toString(16).padStart(2, "0")) .join(""); + // Static import puts key-generation crypto in the initial web bundle. + const { privateKeyToAccount } = await import("viem/accounts"); const account = privateKeyToAccount(`0x${hexKey}`); const newKey = { privateKey: multibaseKey, diff --git a/js/e2e-web/flows/lazy-navigation.spec.ts b/js/e2e-web/flows/lazy-navigation.spec.ts new file mode 100644 index 000000000..280ac6e09 --- /dev/null +++ b/js/e2e-web/flows/lazy-navigation.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test"; + +// Native counterpart: .maestro/logged-out/lazy-navigation.yaml. Only web +// exports fetch separate chunks; native imports resolve from the installed bundle. +test("lazy navigation: defer settings until opened and keep shell usable", async ({ + page, +}) => { + const scripts: string[] = []; + page.on("request", (request) => { + if (request.resourceType() === "script") scripts.push(request.url()); + }); + + await page.goto("/"); + await expect(page.getByTestId("home-stream-card").first()).toBeVisible(); + expect(scripts.some((url) => /\/settings-[^/]+\.js$/.test(url))).toBe(false); + expect( + scripts.some((url) => /\/advanced-category-settings-[^/]+\.js$/.test(url)), + ).toBe(false); + + let releaseChunk!: () => void; + const heldChunk = new Promise((resolve) => { + releaseChunk = resolve; + }); + await page.route(/\/settings-[^/]+\.js$/, async (route) => { + await heldChunk; + await route.continue(); + }); + + try { + await page + .getByRole("link", { name: "Settings", exact: true }) + .first() + .click(); + await expect(page.getByLabel("Loading screen")).toBeVisible(); + await expect( + page.getByRole("link", { name: "Home", exact: true }).first(), + ).toBeVisible(); + // Leaving a pending screen must not leave the entire app suspended. + await page.getByRole("link", { name: "Home", exact: true }).first().click(); + await expect(page.getByTestId("home-stream-card").first()).toBeVisible(); + } finally { + releaseChunk(); + } + + await page + .getByRole("link", { name: "Settings", exact: true }) + .first() + .click(); + await expect(page.getByText("Advanced", { exact: true })).toBeVisible(); + expect(scripts.some((url) => /\/settings-[^/]+\.js$/.test(url))).toBe(true); + expect( + scripts.some((url) => /\/advanced-category-settings-[^/]+\.js$/.test(url)), + ).toBe(false); + + await page.getByText("Advanced", { exact: true }).click(); + await expect(page.getByTestId("settings-use-custom-node")).toBeVisible(); + expect( + scripts.some((url) => /\/advanced-category-settings-[^/]+\.js$/.test(url)), + ).toBe(true); + + // A cold deep link must load the same category without visiting Settings first. + await page.reload(); + await expect(page.getByTestId("settings-use-custom-node")).toBeVisible(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2e72e1ae..bd0c7b2aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -399,6 +399,9 @@ importers: emojibase-data: specifier: ^17.0.0 version: 17.0.0(emojibase@17.0.0) + expo-atlas: + specifier: ^0.4.3 + version: 0.4.3(expo@55.0.6) i18next-parser: specifier: ^9.0.1 version: 9.4.0 @@ -3613,6 +3616,9 @@ packages: '@expo/sdk-runtime-versions@1.0.0': resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} + '@expo/server@0.5.3': + resolution: {integrity: sha512-WXsWzeBs5v/h0PUfHyNLLz07rwwO5myQ1A5DGYewyyGLmsyl61yVCe8AgAlp1wkiMsqhj2hZqI2u3K10QnCMrQ==} + '@expo/spawn-async@1.7.2': resolution: {integrity: sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==} engines: {node: '>=12'} @@ -8405,6 +8411,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + batch@0.6.1: resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} @@ -8463,10 +8473,6 @@ packages: bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - body-parser@1.20.4: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -8604,10 +8610,6 @@ packages: resolution: {integrity: sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg==} engines: {node: '>=12.17'} - bytes@3.0.0: - resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} - engines: {node: '>= 0.8'} - bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -9072,10 +9074,6 @@ packages: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} - compression@1.7.4: - resolution: {integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==} - engines: {node: '>= 0.8.0'} - compression@1.8.1: resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} @@ -9154,16 +9152,9 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} - cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - cookie@0.6.0: - resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} - engines: {node: '>= 0.6'} - cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -10159,6 +10150,12 @@ packages: react: '*' react-native: '*' + expo-atlas@0.4.3: + resolution: {integrity: sha512-nN2bouxFvMsqZqLl0ka+eI9Ofida0PcuoE4v+7fHlgyp95X2cCL8Acf0nRKXmmIBEYazdw0d7BAOZfC0b41oxA==} + hasBin: true + peerDependencies: + expo: '*' + expo-build-properties@55.0.9: resolution: {integrity: sha512-p0rNHW/6ghKsvjlUn2DQfbLYuTB6ba+15SeTPOz5BYbyU1F/0F/YyxBtHdmWitqgDPn6VgXQeKhiNC1fMwYDpg==} peerDependencies: @@ -10371,10 +10368,6 @@ packages: peerDependencies: express: ^4.0.0 || ^5.0.0-alpha.1 - express@4.21.0: - resolution: {integrity: sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==} - engines: {node: '>= 0.10.0'} - express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} @@ -10542,10 +10535,6 @@ packages: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} - engines: {node: '>= 0.8'} - finalhandler@1.3.2: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} @@ -10662,6 +10651,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + freeport-async@2.0.0: + resolution: {integrity: sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==} + engines: {node: '>=8'} + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -11271,10 +11264,6 @@ packages: resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==} engines: {node: '>= 0.6'} - http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} - engines: {node: '>= 0.8'} - http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -13222,6 +13211,10 @@ packages: moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + morgan@1.12.1: + resolution: {integrity: sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==} + engines: {node: '>= 0.8.0'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -13580,10 +13573,6 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.0.2: - resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} - engines: {node: '>= 0.8'} - on-headers@1.1.0: resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} engines: {node: '>= 0.8'} @@ -13921,9 +13910,6 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-to-regexp@0.1.10: - resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==} - path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} @@ -14457,10 +14443,6 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} - qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} @@ -14525,10 +14507,6 @@ packages: rate-limiter-flexible@2.4.2: resolution: {integrity: sha512-rMATGGOdO1suFyf/mI5LYhts71g1sbdhmd6YvdiXO2gJnd42Tt6QS4JUKJKSWVVkMtBacm6l40FR7Trjo6Iruw==} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} - engines: {node: '>= 0.8'} - raw-body@2.5.3: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} @@ -15337,10 +15315,6 @@ packages: engines: {node: '>=10'} hasBin: true - send@0.19.0: - resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} - engines: {node: '>= 0.8.0'} - send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -15370,10 +15344,6 @@ packages: resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==} engines: {node: '>= 0.8.0'} - serve-static@1.16.2: - resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} - engines: {node: '>= 0.8.0'} - serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -15459,10 +15429,6 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.0.6: - resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} - engines: {node: '>= 0.4'} - side-channel@1.1.0: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} @@ -15717,10 +15683,6 @@ packages: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} - statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} - engines: {node: '>= 0.8'} - statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -21554,6 +21516,15 @@ snapshots: '@expo/sdk-runtime-versions@1.0.0': {} + '@expo/server@0.5.3': + dependencies: + abort-controller: 3.0.0 + debug: 4.4.3(supports-color@5.5.0) + source-map-support: 0.5.21 + undici: 6.24.0 + transitivePeerDependencies: + - supports-color + '@expo/spawn-async@1.7.2': dependencies: cross-spawn: 7.0.6 @@ -23675,7 +23646,7 @@ snapshots: invariant: 2.2.4 nullthrows: 1.1.1 open: 7.4.2 - serve-static: 1.16.2 + serve-static: 1.16.3 ws: 7.5.10(bufferutil@4.0.8)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil @@ -27268,6 +27239,10 @@ snapshots: baseline-browser-mapping@2.10.7: {} + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + batch@0.6.1: {} bcp-47-match@2.0.3: {} @@ -27331,23 +27306,6 @@ snapshots: bn.js@4.12.3: {} - body-parser@1.20.3: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - body-parser@1.20.4: dependencies: bytes: 3.1.2 @@ -27529,8 +27487,6 @@ snapshots: byte-size@8.1.1: {} - bytes@3.0.0: {} - bytes@3.1.2: {} cac@6.7.14: {} @@ -27994,18 +27950,6 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.7.4: - dependencies: - accepts: 1.3.8 - bytes: 3.0.0 - compressible: 2.0.18 - debug: 2.6.9 - on-headers: 1.0.2 - safe-buffer: 5.1.2 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - compression@1.8.1: dependencies: bytes: 3.1.2 @@ -28108,12 +28052,8 @@ snapshots: cookie-es@3.1.1: {} - cookie-signature@1.0.6: {} - cookie-signature@1.0.7: {} - cookie@0.6.0: {} - cookie@0.7.2: {} cookie@1.1.1: {} @@ -29366,6 +29306,24 @@ snapshots: - supports-color - typescript + expo-atlas@0.4.3(expo@55.0.6): + dependencies: + '@expo/server': 0.5.3 + arg: 5.0.2 + chalk: 4.1.2 + compression: 1.8.1 + connect: 3.7.0 + expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + express: 4.22.1 + freeport-async: 2.0.0 + getenv: 2.0.0 + morgan: 1.12.1 + open: 8.4.2 + serve-static: 1.16.3 + stream-json: 1.8.0 + transitivePeerDependencies: + - supports-color + expo-build-properties@55.0.9(expo@55.0.6): dependencies: '@expo/schema-utils': 55.0.2 @@ -29644,42 +29602,6 @@ snapshots: - bufferutil - utf-8-validate - express@4.21.0: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.3 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.6.0 - cookie-signature: 1.0.6 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.1 - fresh: 0.5.2 - http-errors: 2.0.0 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.10 - proxy-addr: 2.0.7 - qs: 6.13.0 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.0 - serve-static: 1.16.2 - setprototypeof: 1.2.0 - statuses: 2.0.1 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - express@4.22.1: dependencies: accepts: 1.3.8 @@ -29894,18 +29816,6 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@1.3.1: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.1 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - finalhandler@1.3.2: dependencies: debug: 2.6.9 @@ -30095,6 +30005,8 @@ snapshots: forwarded@0.2.0: {} + freeport-async@2.0.0: {} + fresh@0.5.2: {} frimousse@0.3.0(react@19.2.0)(typescript@5.9.3): @@ -30965,14 +30877,6 @@ snapshots: setprototypeof: 1.1.0 statuses: 1.5.0 - http-errors@2.0.0: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.1 - toidentifier: 1.0.1 - http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -31302,7 +31206,7 @@ snapshots: dependencies: es-errors: 1.3.0 hasown: 2.0.2 - side-channel: 1.0.6 + side-channel: 1.1.0 internal-slot@1.1.0: dependencies: @@ -32527,7 +32431,7 @@ snapshots: minipass-fetch: 3.0.5 minipass-flush: 1.0.5 minipass-pipeline: 1.2.4 - negotiator: 0.6.3 + negotiator: 0.6.4 proc-log: 4.2.0 promise-retry: 2.0.1 ssri: 10.0.6 @@ -33651,6 +33555,16 @@ snapshots: moment@2.30.1: {} + morgan@1.12.1: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.4.1 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + mrmime@2.0.1: {} ms@2.0.0: {} @@ -34060,8 +33974,6 @@ snapshots: dependencies: ee-first: 1.1.1 - on-headers@1.0.2: {} - on-headers@1.1.0: {} once@1.4.0: @@ -34529,8 +34441,6 @@ snapshots: lru-cache: 11.2.6 minipass: 7.1.3 - path-to-regexp@0.1.10: {} - path-to-regexp@0.1.12: {} path-to-regexp@6.3.0: {} @@ -35027,10 +34937,6 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 - qs@6.13.0: - dependencies: - side-channel: 1.0.6 - qs@6.14.2: dependencies: side-channel: 1.1.0 @@ -35145,13 +35051,6 @@ snapshots: rate-limiter-flexible@2.4.2: {} - raw-body@2.5.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - raw-body@2.5.3: dependencies: bytes: 3.1.2 @@ -36223,24 +36122,6 @@ snapshots: semver@7.7.4: {} - send@0.19.0: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 1.0.2 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.0 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.1 - transitivePeerDependencies: - - supports-color - send@0.19.2: dependencies: debug: 2.6.9 @@ -36288,15 +36169,6 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@1.16.2: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.0 - transitivePeerDependencies: - - supports-color - serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -36444,13 +36316,6 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.0.6: - dependencies: - call-bind: 1.0.8 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.2 - side-channel@1.1.0: dependencies: es-errors: 1.3.0 @@ -36737,8 +36602,6 @@ snapshots: statuses@1.5.0: {} - statuses@2.0.1: {} - statuses@2.0.2: {} std-env@4.1.0: {} @@ -38152,10 +38015,10 @@ snapshots: bonjour-service: 1.2.1 chokidar: 3.6.0 colorette: 2.0.20 - compression: 1.7.4 + compression: 1.8.1 connect-history-api-fallback: 2.0.0 default-gateway: 6.0.3 - express: 4.21.0 + express: 4.22.1 graceful-fs: 4.2.11 html-entities: 2.5.2 http-proxy-middleware: 2.0.6(@types/express@4.17.21)(debug@4.4.0) -- 2.54.0 From 0fec0858f4dc64279a6a43050ac2915aa65fc453 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Fri, 9 Oct 2026 16:36:07 -0700 Subject: [PATCH 15/76] Keep navigation usable after failed lazy screen downloads --- js/app/README.md | 11 +++++---- js/app/package.json | 2 +- js/app/src/lazy-screen.tsx | 30 +++++++++++++++++++----- js/e2e-web/flows/lazy-navigation.spec.ts | 30 ++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/js/app/README.md b/js/app/README.md index ef38c1636..ce0f80996 100644 --- a/js/app/README.md +++ b/js/app/README.md @@ -40,8 +40,10 @@ Then run `pnpm app ios` (or `pnpm app android`). Bump `runtimeVersion` in ## Web bundle splitting and Atlas -Secondary screens use module-scope `lazyScreen` imports with a screen-local -Suspense boundary. Keep the home feed and navigation shell eager; use direct +Secondary screens use module-scope `lazyScreen` imports with screen-local +Suspense and error boundaries. A failed download leaves navigation usable, and +retry creates a fresh lazy import instead of reusing React's cached rejection. +Keep the home feed and navigation shell eager; use direct imports instead of barrels that re-export deferred screens. The broadcaster front door also loads its stream/video screen lazily. HLS playback and stream-key generation load their libraries only when needed. @@ -56,7 +58,8 @@ Run the production analyzer from the builder container: pnpm app analyze:web ``` -Open the URL printed by Expo Atlas (in a browser that can reach the container). +The command prepares generated brand assets before exporting. Open the URL +printed by Expo Atlas (in a browser that can reach the container). The export is in `.expo/atlas-web`, leaving the embedded `dist` bundle untouched. Atlas's module graph includes deferred modules too: measure initial download size by summing **all** scripts referenced by `.expo/atlas-web/index.html`, including @@ -68,7 +71,7 @@ keep it local. Rebuild with `make app` and `make dev` before exercising embedded UI with `hack/e2e-web-local.sh`. `lazy-navigation.spec.ts` checks deferred settings requests, navigation away from -a pending chunk, and a cold settings deep link. The platform-neutral +a pending chunk, recovery from a failed chunk, and a cold settings deep link. The platform-neutral `.maestro/logged-out/lazy-navigation.yaml` covers first-use navigation and deep links on iOS/Android. diff --git a/js/app/package.json b/js/app/package.json index aea2f3ee7..125a17d85 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -19,7 +19,7 @@ "build": "pnpm run build:web && pnpm run prebuild", "build:web": "pnpm run brand && node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json", "export": "expo export --dump-sourcemap || expo export --dump-sourcemap", - "analyze:web": "EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && expo-atlas --no-open", + "analyze:web": "pnpm run brand && EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && expo-atlas --no-open", "check": "pnpm --filter @streamplace/i18n compile && bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'", "prebuild": "pnpm run brand && EXPO_NO_GIT_STATUS=1 expo prebuild --clean && sed -i.bak 's/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m/' android/gradle.properties && pnpm run find-node", "prepare": "which pod && pnpm run prepare-ios || echo 'not a mac, not installing pods' && mkdir -p ./public && cp -r ../i18n/public/locales ./public/", diff --git a/js/app/src/lazy-screen.tsx b/js/app/src/lazy-screen.tsx index 5479ba7e3..15e1f6a7b 100644 --- a/js/app/src/lazy-screen.tsx +++ b/js/app/src/lazy-screen.tsx @@ -1,6 +1,11 @@ -import { Loader, zero } from "@streamplace/components"; +import { + AppCrashScreen, + ErrorBoundary, + Loader, + zero, +} from "@streamplace/components"; import type { ComponentProps, ComponentType } from "react"; -import { lazy, Suspense } from "react"; +import { lazy, Suspense, useState } from "react"; import { View } from "react-native"; function ScreenFallback() { @@ -16,12 +21,25 @@ function ScreenFallback() { export function lazyScreen>( load: () => Promise<{ default: T }>, ) { - const Screen = lazy(load); + const InitialScreen = lazy(load); return function LazyScreen(props: ComponentProps) { + const [Screen, setScreen] = useState(() => InitialScreen); return ( - }> - - + ( + { + // React.lazy caches rejection; a retry needs a fresh lazy component. + setScreen(() => lazy(load)); + reset(); + }} + /> + )} + > + }> + + + ); }; } diff --git a/js/e2e-web/flows/lazy-navigation.spec.ts b/js/e2e-web/flows/lazy-navigation.spec.ts index 280ac6e09..61a2819ff 100644 --- a/js/e2e-web/flows/lazy-navigation.spec.ts +++ b/js/e2e-web/flows/lazy-navigation.spec.ts @@ -62,3 +62,33 @@ test("lazy navigation: defer settings until opened and keep shell usable", async await page.reload(); await expect(page.getByTestId("settings-use-custom-node")).toBeVisible(); }); + +test("lazy navigation: retry a failed chunk without losing navigation", async ({ + page, +}) => { + let attempts = 0; + await page.route(/\/settings-[^/]+\.js$/, async (route) => { + attempts++; + if (attempts === 1) { + await route.fulfill({ status: 503, body: "Temporarily unavailable" }); + } else { + await route.continue(); + } + }); + + await page.goto("/"); + await expect(page.getByTestId("home-stream-card").first()).toBeVisible(); + await page + .getByRole("link", { name: "Settings", exact: true }) + .first() + .click(); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); + await expect( + page.getByRole("link", { name: "Home", exact: true }).first(), + ).toBeVisible(); + await page.getByRole("button", { name: "Try again" }).click(); + await expect(page.getByText("Advanced", { exact: true })).toBeVisible(); + expect(attempts).toBe(2); + await page.getByRole("link", { name: "Home", exact: true }).first().click(); + await expect(page.getByTestId("home-stream-card").first()).toBeVisible(); +}); -- 2.54.0 From 10d2a2d85ab72741f6f5f083d253ab42efb7cba3 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Fri, 9 Oct 2026 17:33:47 -0700 Subject: [PATCH 16/76] Show a bare web bootstrap before loading Expo application scripts --- js/app/README.md | 29 +++++++++-- js/app/package.json | 4 +- js/app/scripts/bootstrap-web.mjs | 81 ++++++++++++++++++++++++++++++ js/app/scripts/web-bootstrap.js | 45 +++++++++++++++++ js/e2e-web/flows/bootstrap.spec.ts | 67 ++++++++++++++++++++++++ js/e2e-web/playwright.config.ts | 6 +-- 6 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 js/app/scripts/bootstrap-web.mjs create mode 100644 js/app/scripts/web-bootstrap.js create mode 100644 js/e2e-web/flows/bootstrap.spec.ts diff --git a/js/app/README.md b/js/app/README.md index ce0f80996..330af035f 100644 --- a/js/app/README.md +++ b/js/app/README.md @@ -52,6 +52,19 @@ Expo splits production **web** exports. iOS/Android still include these modules in the installed bundle; the same loading boundaries work without downloading web chunks. +Production web HTML renders a dependency-free bootstrap spinner before loading +Expo's scripts. `scripts/bootstrap-web.mjs` runs after normal and Atlas exports: +it replaces the eager script tags with a hashed, bare DOM bootloader, preserving +Expo's runtime/common/entry order and asset base URL. React replaces the spinner +inside `#root` on its first committed render. Failed downloads offer a retry +without replaying successfully loaded scripts. Styles come from design tokens +at build time; no React, theme provider, fonts, or app modules load to show it. +Development Metro and installed iOS/Android entrypoints are unchanged. + +This reduces **bytes required for the first loading UI**, not the bytes required +for a usable Home screen. The app still downloads automatically after that first +paint; route-level chunks remain deferred until opened. + Run the production analyzer from the builder container: ```sh @@ -61,15 +74,23 @@ pnpm app analyze:web The command prepares generated brand assets before exporting. Open the URL printed by Expo Atlas (in a browser that can reach the container). The export is in `.expo/atlas-web`, leaving the embedded `dist` bundle untouched. -Atlas's module graph includes deferred modules too: measure initial download size -by summing **all** scripts referenced by `.expo/atlas-web/index.html`, including -Expo's common/runtime chunks, rather than just the entrypoint. Check chunk source -maps and browser network requests to confirm heavy modules remain deferred. +Atlas's graph still describes the full Expo application, including deferred +modules; the separately emitted bootstrap is not in that graph. Measure the +bootstrap by summing the HTML and **all** script URLs directly referenced by +`.expo/atlas-web/index.html`. The `bootstrap-scripts` JSON manifest lists the +runtime/common/entry scripts that download after paint: include those too when +measuring bytes required for Home. Check source maps and browser network requests +to confirm HLS, stream-key crypto, and secondary screens remain deferred. `.expo/atlas.jsonl` contains source code and inlined public environment variables; keep it local. Rebuild with `make app` and `make dev` before exercising embedded UI with `hack/e2e-web-local.sh`. +`bootstrap.spec.ts` runs in Chromium and Firefox: it enforces a sub-1-MB first +loading payload, holds app downloads to check the visible spinner, then checks +the handoff to Home and recovery from a failed common-chunk request. This +HTML-only bootstrap does not apply to native installs. + `lazy-navigation.spec.ts` checks deferred settings requests, navigation away from a pending chunk, recovery from a failed chunk, and a cold settings deep link. The platform-neutral `.maestro/logged-out/lazy-navigation.yaml` covers first-use navigation and deep diff --git a/js/app/package.json b/js/app/package.json index 125a17d85..39e740ed8 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -18,8 +18,8 @@ "brand": "pnpm --filter @streamplace/brand generate", "build": "pnpm run build:web && pnpm run prebuild", "build:web": "pnpm run brand && node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json", - "export": "expo export --dump-sourcemap || expo export --dump-sourcemap", - "analyze:web": "pnpm run brand && EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && expo-atlas --no-open", + "export": "(expo export --dump-sourcemap || expo export --dump-sourcemap) && node --experimental-strip-types scripts/bootstrap-web.mjs", + "analyze:web": "pnpm run brand && EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && node --experimental-strip-types scripts/bootstrap-web.mjs .expo/atlas-web && expo-atlas --no-open", "check": "pnpm --filter @streamplace/i18n compile && bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'", "prebuild": "pnpm run brand && EXPO_NO_GIT_STATUS=1 expo prebuild --clean && sed -i.bak 's/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m/' android/gradle.properties && pnpm run find-node", "prepare": "which pod && pnpm run prepare-ios || echo 'not a mac, not installing pods' && mkdir -p ./public && cp -r ../i18n/public/locales ./public/", diff --git a/js/app/scripts/bootstrap-web.mjs b/js/app/scripts/bootstrap-web.mjs new file mode 100644 index 000000000..1b30f008b --- /dev/null +++ b/js/app/scripts/bootstrap-web.mjs @@ -0,0 +1,81 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + borderRadius, + motion, + spacing, + surfaces, + textAlphas, + touchTargets, + typeScale, +} from "../../components/src/lib/theme/tokens.ts"; + +// Expo's shared chunk is eager even when every app screen is lazy. Keep its +// generated bundles intact, but let a tiny bootloader start them after paint. +const output = process.argv[2] ?? "dist"; +const htmlPath = path.join(output, "index.html"); +let html = await readFile(htmlPath, "utf8"); +const scripts = []; +html = html.replace( + /]*\bsrc="([^"]+)"[^>]*>\s*<\/script>/g, + (_, src) => { + scripts.push(src); + return ""; + }, +); +if (!scripts.length || !html.includes('
')) { + throw new Error("Expected Expo SPA HTML with scripts and an empty #root"); +} + +const code = await readFile(new URL("./web-bootstrap.js", import.meta.url)); +const hash = createHash("sha256").update(code).digest("hex").slice(0, 16); +const filename = `bootstrap-${hash}.js`; +await writeFile(path.join(output, "_expo/static/js/web", filename), code); + +const css = ` +#web-bootstrap { + flex: 1; display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: ${spacing[4]}px; padding: ${spacing[6]}px; + background: ${surfaces.dark[0]}; color: ${textAlphas.dark[1]}; + font: ${typeScale.base.fontSize}px/${typeScale.base.lineHeight}px system-ui, sans-serif; + text-align: center; +} +#bootstrap-spinner { + width: ${spacing[8]}px; height: ${spacing[8]}px; + border: ${spacing[1]}px solid ${textAlphas.dark[4]}; border-top-color: currentColor; + border-radius: ${borderRadius.full}px; + animation: bootstrap-spin ${motion.slow * 3}ms linear infinite; +} +#web-bootstrap [hidden] { display: none; } +#bootstrap-retry { + font: inherit; color: inherit; background: ${surfaces.dark[2]}; + padding: ${spacing[2]}px ${spacing[4]}px; border: none; + border-radius: ${borderRadius.md}px; min-height: ${touchTargets.minimum}px; cursor: pointer; +} +#bootstrap-retry:focus-visible { outline: ${spacing[1] / 2}px solid currentColor; outline-offset: ${spacing[1] / 2}px; } +@keyframes bootstrap-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { #bootstrap-spinner { animation: none; } } +`; +html = html.replace("", ``); +html = html.replace( + '
', + `
+ +
Loading Streamplace
+ +
`, +); +// Escape '<' so a filename cannot terminate the JSON script element. +const manifest = JSON.stringify(scripts).replace(/", + ` +`, +); +await writeFile(htmlPath, html); +console.log( + `Web bootstrap: ${code.length} initial JS bytes; ${scripts.length} Expo scripts deferred until after paint`, +); diff --git a/js/app/scripts/web-bootstrap.js b/js/app/scripts/web-bootstrap.js new file mode 100644 index 000000000..a5e339f00 --- /dev/null +++ b/js/app/scripts/web-bootstrap.js @@ -0,0 +1,45 @@ +// No React, Expo, or app imports: this runs before any application scripts. +const status = document.getElementById("bootstrap-status"); +const spinner = document.getElementById("bootstrap-spinner"); +const retry = document.getElementById("bootstrap-retry"); +const scripts = JSON.parse( + document.getElementById("bootstrap-scripts").textContent, +); +let loaded = 0; + +async function boot() { + retry.hidden = true; + retry.disabled = true; + spinner.hidden = false; + status.textContent = "Loading Streamplace"; + try { + // Keep Expo's runtime/common/entry order. On retry, don't replay scripts + // that already ran: in particular, reinitializing Metro loses its modules. + for (; loaded < scripts.length; loaded++) { + await new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = scripts[loaded]; + script.onload = () => { + script.remove(); + resolve(); + }; + script.onerror = () => { + script.remove(); + reject(new Error("Could not download " + script.src)); + }; + document.head.appendChild(script); + }); + } + // React replaces the bootstrap inside #root when its first render commits. + } catch (error) { + console.error("Streamplace bootstrap:", error); + spinner.hidden = true; + status.textContent = "Could not load Streamplace. Check your connection."; + retry.disabled = false; + retry.hidden = false; + } +} + +retry.addEventListener("click", boot); +// Let the lightweight HTML paint before downloading/evaluating the app. +requestAnimationFrame(() => requestAnimationFrame(boot)); diff --git a/js/e2e-web/flows/bootstrap.spec.ts b/js/e2e-web/flows/bootstrap.spec.ts new file mode 100644 index 000000000..f3983a3b0 --- /dev/null +++ b/js/e2e-web/flows/bootstrap.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from "@playwright/test"; + +// Web-only: native installs bundle the app and retain the existing entrypoint. +test("bootstrap: paint loading UI before app downloads, below 1 MB", async ({ + page, + request, +}) => { + const html = await (await request.get("/")).text(); + const initialScripts = [...html.matchAll(/]*src="([^"]+)"/g)].map( + (match) => match[1], + ); + let bytes = Buffer.byteLength(html); + for (const src of initialScripts) { + const response = await request.get(src); + expect(response.ok()).toBe(true); + bytes += (await response.body()).length; + } + // Includes the HTML and every initial script, not just the smallest chunk. + expect(bytes).toBeLessThan(1_000_000); + + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route(/\/__expo-metro-runtime-[^/]+\.js$/, async (route) => { + await held; + await route.continue(); + }); + try { + await page.goto("/"); + await expect(page.getByRole("status")).toHaveText("Loading Streamplace"); + await expect(page.locator("#bootstrap-spinner")).toBeVisible(); + await expect(page.getByTestId("home-stream-card")).toHaveCount(0); + } finally { + release(); + } + await expect(page.getByTestId("home-stream-card").first()).toBeVisible(); + await expect(page.locator("#web-bootstrap")).toHaveCount(0); +}); + +test("bootstrap: retry a failed common chunk without replaying Metro", async ({ + page, +}) => { + let attempts = 0; + let runtimeRequests = 0; + page.on("request", (request) => { + if (/\/__expo-metro-runtime-[^/]+\.js$/.test(request.url())) + runtimeRequests++; + }); + await page.route(/\/__common-[^/]+\.js$/, async (route) => { + attempts++; + if (attempts === 1) { + await route.fulfill({ status: 503, body: "Temporarily unavailable" }); + } else { + await route.continue(); + } + }); + await page.goto("/"); + await expect(page.getByRole("status")).toHaveText( + "Could not load Streamplace. Check your connection.", + ); + await expect(page.locator("#bootstrap-spinner")).toBeHidden(); + await page.getByRole("button", { name: "Try again" }).click(); + await expect(page.getByTestId("home-stream-card").first()).toBeVisible(); + expect(attempts).toBe(2); + expect(runtimeRequests).toBe(1); +}); diff --git a/js/e2e-web/playwright.config.ts b/js/e2e-web/playwright.config.ts index 2689db310..2e3cf3b8d 100644 --- a/js/e2e-web/playwright.config.ts +++ b/js/e2e-web/playwright.config.ts @@ -63,11 +63,11 @@ export default defineConfig({ name: "chromium", use: { ...devices["Desktop Chrome"] }, }, - // Firefox runs only the flows that cover a Firefox-specific bug, and none - // that log in: the harness's certificate pinning above is a Chromium flag. + // Firefox covers browser-specific behavior and the bare web bootstrap. + // No login flows: the harness's certificate pinning above is a Chromium flag. { name: "firefox", - testMatch: /chat-wheel-scroll\.spec\.ts$/, + testMatch: /(?:chat-wheel-scroll|bootstrap)\.spec\.ts$/, use: { ...devices["Desktop Firefox"], // the launch flags above are Chromium's -- 2.54.0 From 601ce338767ca9e0313abd455b01630fcf6685ef Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 11 Oct 2026 13:49:11 -0700 Subject: [PATCH 17/76] Build web bootstrap without native Node TypeScript support --- js/app/README.md | 4 ++++ js/app/package.json | 4 ++-- js/app/scripts/bootstrap-web.mjs | 17 +++++++++++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/js/app/README.md b/js/app/README.md index 330af035f..88bba8c7f 100644 --- a/js/app/README.md +++ b/js/app/README.md @@ -61,6 +61,10 @@ without replaying successfully loaded scripts. Styles come from design tokens at build time; no React, theme provider, fonts, or app modules load to show it. Development Metro and installed iOS/Android entrypoints are unchanged. +The bootstrap build uses the project's installed TypeScript compiler to read +design tokens. It does not require Node's optional native TypeScript support or +`--experimental-strip-types`; the existing Node 22 requirement is unchanged. + This reduces **bytes required for the first loading UI**, not the bytes required for a usable Home screen. The app still downloads automatically after that first paint; route-level chunks remain deferred until opened. diff --git a/js/app/package.json b/js/app/package.json index 39e740ed8..d00a1daf1 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -18,8 +18,8 @@ "brand": "pnpm --filter @streamplace/brand generate", "build": "pnpm run build:web && pnpm run prebuild", "build:web": "pnpm run brand && node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json", - "export": "(expo export --dump-sourcemap || expo export --dump-sourcemap) && node --experimental-strip-types scripts/bootstrap-web.mjs", - "analyze:web": "pnpm run brand && EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && node --experimental-strip-types scripts/bootstrap-web.mjs .expo/atlas-web && expo-atlas --no-open", + "export": "(expo export --dump-sourcemap || expo export --dump-sourcemap) && node scripts/bootstrap-web.mjs", + "analyze:web": "pnpm run brand && EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && node scripts/bootstrap-web.mjs .expo/atlas-web && expo-atlas --no-open", "check": "pnpm --filter @streamplace/i18n compile && bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'", "prebuild": "pnpm run brand && EXPO_NO_GIT_STATUS=1 expo prebuild --clean && sed -i.bak 's/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m/' android/gradle.properties && pnpm run find-node", "prepare": "which pod && pnpm run prepare-ios || echo 'not a mac, not installing pods' && mkdir -p ./public && cp -r ../i18n/public/locales ./public/", diff --git a/js/app/scripts/bootstrap-web.mjs b/js/app/scripts/bootstrap-web.mjs index 1b30f008b..a1fbf63f8 100644 --- a/js/app/scripts/bootstrap-web.mjs +++ b/js/app/scripts/bootstrap-web.mjs @@ -1,7 +1,18 @@ import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { +import ts from "typescript"; + +// Tokens have only type imports. Compile them with the project's TypeScript +// dependency so this works even when Node was built without type stripping. +const { outputText } = ts.transpileModule( + await readFile( + new URL("../../components/src/lib/theme/tokens.ts", import.meta.url), + "utf8", + ), + { compilerOptions: { module: ts.ModuleKind.ESNext } }, +); +const { borderRadius, motion, spacing, @@ -9,7 +20,9 @@ import { textAlphas, touchTargets, typeScale, -} from "../../components/src/lib/theme/tokens.ts"; +} = await import( + `data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}` +); // Expo's shared chunk is eager even when every app screen is lazy. Keep its // generated bundles intact, but let a tiny bootloader start them after paint. -- 2.54.0 From cd472df851369b8c8339c8cd8e516b372c5a7149 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 11 Oct 2026 14:51:33 -0700 Subject: [PATCH 18/76] Brand the bare web spinner before loading the application --- js/app/README.md | 14 ++- js/app/components/settings/branding-admin.tsx | 4 + js/app/scripts/bootstrap-web.mjs | 20 +++-- js/app/scripts/web-bootstrap.js | 8 +- js/components/src/lib/theme/tokens.ts | 7 ++ js/e2e-web/flows/bootstrap-branding.spec.ts | 89 +++++++++++++++++++ js/e2e-web/flows/bootstrap.spec.ts | 14 +-- pkg/linking/linking.go | 33 ++++--- pkg/linking/linking_test.go | 16 ++++ 9 files changed, 177 insertions(+), 28 deletions(-) create mode 100644 js/e2e-web/flows/bootstrap-branding.spec.ts diff --git a/js/app/README.md b/js/app/README.md index 88bba8c7f..56dde7b42 100644 --- a/js/app/README.md +++ b/js/app/README.md @@ -61,6 +61,13 @@ without replaying successfully loaded scripts. Styles come from design tokens at build time; no React, theme provider, fonts, or app modules load to show it. Development Metro and installed iOS/Android entrypoints are unchanged. +The loading UI uses the regular web Loader's SVG ring with no visible loading +text (an accessible progress label remains). `pkg/linking` injects the node's +existing `primaryColor` and dark `backgroundColor` as CSS variables in the +initial HTML, so the spinner and page are branded before JavaScript runs. +Unset or invalid colors retain the design-token defaults; only validated hex +colors are embedded in CSS. Static hosting uses those defaults too. + The bootstrap build uses the project's installed TypeScript compiler to read design tokens. It does not require Node's optional native TypeScript support or `--experimental-strip-types`; the existing Node 22 requirement is unchanged. @@ -92,8 +99,11 @@ UI with `hack/e2e-web-local.sh`. `bootstrap.spec.ts` runs in Chromium and Firefox: it enforces a sub-1-MB first loading payload, holds app downloads to check the visible spinner, then checks -the handoff to Home and recovery from a failed common-chunk request. This -HTML-only bootstrap does not apply to native installs. +the handoff to Home and recovery from a failed common-chunk request. +`bootstrap-branding.spec.ts` edits the real node's branding through the admin UI, +checks spinner/page colors while Expo downloads are held, and restores the +original branding bundle. This HTML-only bootstrap does not apply to native +installs. `lazy-navigation.spec.ts` checks deferred settings requests, navigation away from a pending chunk, recovery from a failed chunk, and a cold settings deep link. The platform-neutral diff --git a/js/app/components/settings/branding-admin.tsx b/js/app/components/settings/branding-admin.tsx index 2276bb135..3df10379b 100644 --- a/js/app/components/settings/branding-admin.tsx +++ b/js/app/components/settings/branding-admin.tsx @@ -1252,12 +1252,14 @@ export function BrandingAdmin() { > `, ); diff --git a/js/app/scripts/web-bootstrap.js b/js/app/scripts/web-bootstrap.js index a5e339f00..b398018f9 100644 --- a/js/app/scripts/web-bootstrap.js +++ b/js/app/scripts/web-bootstrap.js @@ -10,8 +10,9 @@ let loaded = 0; async function boot() { retry.hidden = true; retry.disabled = true; - spinner.hidden = false; - status.textContent = "Loading Streamplace"; + spinner.removeAttribute("hidden"); + status.hidden = true; + status.textContent = ""; try { // Keep Expo's runtime/common/entry order. On retry, don't replay scripts // that already ran: in particular, reinitializing Metro loses its modules. @@ -33,8 +34,9 @@ async function boot() { // React replaces the bootstrap inside #root when its first render commits. } catch (error) { console.error("Streamplace bootstrap:", error); - spinner.hidden = true; + spinner.setAttribute("hidden", ""); status.textContent = "Could not load Streamplace. Check your connection."; + status.hidden = false; retry.disabled = false; retry.hidden = false; } diff --git a/js/components/src/lib/theme/tokens.ts b/js/components/src/lib/theme/tokens.ts index bc9d40d9c..ae722b113 100644 --- a/js/components/src/lib/theme/tokens.ts +++ b/js/components/src/lib/theme/tokens.ts @@ -822,6 +822,13 @@ export const motion = { sheetSpring: { damping: 30, stiffness: 300 }, } as const; +// Match React Native Web's large ActivityIndicator before React is available. +export const loadingIndicator = { + size: 36, + duration: 750, + trackOpacity: 0.2, +} as const; + /** @deprecated use `motion` (fast/base/slow) */ export const animations = { fast: motion.fast, diff --git a/js/e2e-web/flows/bootstrap-branding.spec.ts b/js/e2e-web/flows/bootstrap-branding.spec.ts new file mode 100644 index 000000000..8b8944042 --- /dev/null +++ b/js/e2e-web/flows/bootstrap-branding.spec.ts @@ -0,0 +1,89 @@ +import { expect, test } from "@playwright/test"; +import { loginThroughPds } from "./login"; + +const HTTPS_URL = process.env.SERVER_HTTPS_URL; +// HTML bootstrap is web-only. Real branding edits require the OAuth/HTTPS harness. +test.skip(!HTTPS_URL, "harness started without its HTTPS hostnames"); + +test("bootstrap: persisted branding colors paint before application scripts", async ({ + page, + context, +}) => { + test.setTimeout(4 * 60_000); + await loginThroughPds(page); + await page.goto(`${HTTPS_URL}/settings/branding`); + + // Restore the whole original bundle through the same admin UI in finally. + // The suite uses one worker, so no other flow edits this node concurrently. + const downloading = page.waitForEvent("download"); + await page + .getByRole("button", { name: "Download bundle", exact: true }) + .click(); + const original = test.info().outputPath("original-branding.zip"); + await (await downloading).saveAs(original); + const preview = await context.newPage(); + let release = () => {}; + try { + for (const [key, color] of [ + ["primaryColor", "#22d3ee"], + ["backgroundColor", "#122334"], + ]) { + await page.getByTestId(`branding-input-${key}`).fill(color); + const updated = page.waitForResponse((response) => + response.url().endsWith("/xrpc/place.stream.branding.updateBlob"), + ); + await page.getByTestId(`branding-update-${key}`).click(); + expect((await updated).ok()).toBe(true); + } + + const held = new Promise((resolve) => { + release = resolve; + }); + await preview.route(/\/__expo-metro-runtime-[^/]+\.js$/, async (route) => { + await held; + await route.continue(); + }); + await preview.goto(HTTPS_URL!, { waitUntil: "domcontentloaded" }); + await expect( + preview.getByRole("progressbar", { name: "Loading" }), + ).toBeVisible(); + await expect(preview.locator("#bootstrap-spinner")).toHaveCSS( + "color", + "rgb(34, 211, 238)", + ); + for (const selector of ["html", "body", "#web-bootstrap"]) { + await expect(preview.locator(selector)).toHaveCSS( + "background-color", + "rgb(18, 35, 52)", + ); + } + await expect(preview.locator("#web-bootstrap")).toHaveJSProperty( + "innerText", + "", + ); + await expect(preview.getByTestId("home-stream-card")).toHaveCount(0); + const screenshot = test.info().outputPath("branded-bootstrap.png"); + await preview.screenshot({ path: screenshot }); + await test.info().attach("branded-bootstrap", { path: screenshot }); + release(); + await expect(preview.getByTestId("home-stream-card").first()).toBeVisible(); + await expect(preview.locator("#web-bootstrap")).toHaveCount(0); + } finally { + release(); + await preview.close(); + const choosing = page.waitForEvent("filechooser"); + await page + .getByRole("button", { name: "Import bundle…", exact: true }) + .click(); + const dryRun = page.waitForResponse((response) => + response.url().includes("/xrpc/place.stream.branding.importBundle"), + ); + await (await choosing).setFiles(original); + expect((await dryRun).ok()).toBe(true); + const applying = page.waitForResponse((response) => + response.url().includes("/xrpc/place.stream.branding.importBundle"), + ); + await page.getByRole("button", { name: "Apply", exact: true }).click(); + expect((await applying).ok()).toBe(true); + } +}); diff --git a/js/e2e-web/flows/bootstrap.spec.ts b/js/e2e-web/flows/bootstrap.spec.ts index f3983a3b0..2f43fa24e 100644 --- a/js/e2e-web/flows/bootstrap.spec.ts +++ b/js/e2e-web/flows/bootstrap.spec.ts @@ -28,8 +28,14 @@ test("bootstrap: paint loading UI before app downloads, below 1 MB", async ({ }); try { await page.goto("/"); - await expect(page.getByRole("status")).toHaveText("Loading Streamplace"); - await expect(page.locator("#bootstrap-spinner")).toBeVisible(); + await expect(page.locator("#bootstrap-status")).toBeHidden(); + await expect( + page.getByRole("progressbar", { name: "Loading" }), + ).toBeVisible(); + await expect(page.locator("#web-bootstrap")).toHaveJSProperty( + "innerText", + "", + ); await expect(page.getByTestId("home-stream-card")).toHaveCount(0); } finally { release(); @@ -56,9 +62,7 @@ test("bootstrap: retry a failed common chunk without replaying Metro", async ({ } }); await page.goto("/"); - await expect(page.getByRole("status")).toHaveText( - "Could not load Streamplace. Check your connection.", - ); + await expect(page.getByRole("status")).toBeVisible(); await expect(page.locator("#bootstrap-spinner")).toBeHidden(); await page.getByRole("button", { name: "Try again" }).click(); await expect(page.getByTestId("home-stream-card").first()).toBeVisible(); diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go index a7f65f7a8..8656d671e 100644 --- a/pkg/linking/linking.go +++ b/pkg/linking/linking.go @@ -66,11 +66,9 @@ const inlineBrandingImageLimit = 96 * 1024 // theme accepts, so a stored value can be dropped straight into a style. var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`) -// bodyBackground returns the node's branded dark background color, if any, -// so the page can paint it before the bundle loads instead of the default -// then re-painting (the "flash of unbranded content"). -func (l *Linker) bodyBackground() string { - v := l.brandingText("backgroundColor") +// brandingColor returns a validated node color safe to embed in CSS. +func (l *Linker) brandingColor(key string) string { + v := l.brandingText(key) if !hexColor.MatchString(v) { return "" } @@ -572,19 +570,34 @@ func (l *Linker) GenerateHTML(ctx context.Context, pc *PageConfig) ([]byte, erro // element too, since that is what mobile browsers show behind their // translucent bars and in overscroll, and as theme-color for the bars // themselves. - if bg := l.bodyBackground(); bg != "" { - style := &html.Node{Type: html.ElementNode, Data: "style"} - head.AppendChild(style) - style.AppendChild(&html.Node{Type: html.TextNode, Data: "html,body{background-color:" + bg + "}"}) + background := l.brandingColor("backgroundColor") + primary := l.brandingColor("primaryColor") + var css strings.Builder + if background != "" { + css.WriteString("html{--bootstrap-background:") + css.WriteString(background) + css.WriteString("}html,body{background-color:") + css.WriteString(background) + css.WriteString("}") head.AppendChild(&html.Node{ Type: html.ElementNode, Data: "meta", Attr: []html.Attribute{ {Key: "name", Val: "theme-color"}, - {Key: "content", Val: bg}, + {Key: "content", Val: background}, }, }) } + if primary != "" { + css.WriteString("html{--bootstrap-primary:") + css.WriteString(primary) + css.WriteString("}") + } + if css.Len() > 0 { + style := &html.Node{Type: html.ElementNode, Data: "style"} + style.AppendChild(&html.Node{Type: html.TextNode, Data: css.String()}) + head.AppendChild(style) + } // Add Sentry DSN script if configured if pc.SentryDSN != "" { diff --git a/pkg/linking/linking_test.go b/pkg/linking/linking_test.go index 1c941c149..a55bdf038 100644 --- a/pkg/linking/linking_test.go +++ b/pkg/linking/linking_test.go @@ -307,4 +307,20 @@ func TestCardTemplates(t *testing.T) { require.Contains(t, page, ``) } +func TestBootstrapBrandingRejectsCSSInjection(t *testing.T) { + ctx := context.Background() + sdb, err := statedb.MakeDB(ctx, &config.CLI{DBURL: ":memory:"}, nil, nil) + require.NoError(t, err) + linker, err := NewLinker(ctx, IndexHTML(t), sdb, &config.CLI{BroadcasterHost: "example.com"}) + require.NoError(t, err) + require.NoError(t, sdb.PutBrandingBlob("did:web:example.com", "backgroundColor", "text/plain", []byte(" #123456 "), nil, nil)) + require.NoError(t, sdb.PutBrandingBlob("did:web:example.com", "primaryColor", "text/plain", []byte(``), nil, nil)) + + out, err := linker.GenerateHTML(ctx, &PageConfig{}) + require.NoError(t, err) + require.Contains(t, string(out), "--bootstrap-background:#123456") + require.NotContains(t, string(out), "--bootstrap-primary:") + require.NotContains(t, string(out), "alert(1)") +} + // Without the home templates the front page keeps the site's own card. -- 2.54.0 From c5eb732bb703ed264f3c88f879375d1b199ea906 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 11 Oct 2026 17:14:39 -0700 Subject: [PATCH 19/76] Retry deferred HLS downloads for recorded playback --- js/app/README.md | 3 ++ .../src/components/mobile-player/video.tsx | 46 +++++++++++-------- js/e2e-web/flows/vod-start-time.spec.ts | 45 ++++++++++++++++++ 3 files changed, 74 insertions(+), 20 deletions(-) diff --git a/js/app/README.md b/js/app/README.md index 56dde7b42..8c2f25b17 100644 --- a/js/app/README.md +++ b/js/app/README.md @@ -47,6 +47,9 @@ Keep the home feed and navigation shell eager; use direct imports instead of barrels that re-export deferred screens. The broadcaster front door also loads its stream/video screen lazily. HLS playback and stream-key generation load their libraries only when needed. +HLS library downloads retry every two seconds when native HLS is unavailable, +including recorded videos; leaving the player cancels the pending retry. +`vod-start-time.spec.ts` checks real playback and seeking after a failed chunk. Expo splits production **web** exports. iOS/Android still include these modules in the installed bundle; the same loading boundaries work without downloading diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx index f194330af..04f12b7b2 100644 --- a/js/components/src/components/mobile-player/video.tsx +++ b/js/components/src/components/mobile-player/video.tsx @@ -384,6 +384,7 @@ export function HLSPlayer(props: VideoProps) { // stops a late setup, and `cleanup` tears down whatever was started. let cancelled = false; let cleanup: (() => void) | undefined; + let importRetry: NodeJS.Timeout | undefined; const startHlsJs = (HlsClass: typeof Hls) => { const hls = new HlsClass({ maxAudioFramesDrift: 20, @@ -496,28 +497,33 @@ export function HLSPlayer(props: VideoProps) { }; const canPlayNative = () => video.canPlayType("application/vnd.apple.mpegurl") !== ""; - import("hls.js").then( - ({ default: HlsClass }) => { - if (cancelled) return; - if (HlsClass.isSupported()) { - cleanup = startHlsJs(HlsClass); - } else if (canPlayNative()) { - cleanup = startNative(); - } - }, - (e) => { - if (cancelled) return; - console.error("failed to load hls.js", e); - if (canPlayNative()) { - cleanup = startNative(); - } else { - // Report it the way the element reports its own errors. - video.dispatchEvent(new Event("error")); - } - }, - ); + const loadHls = () => { + import("hls.js").then( + ({ default: HlsClass }) => { + if (cancelled) return; + if (HlsClass.isSupported()) { + cleanup = startHlsJs(HlsClass); + } else if (canPlayNative()) { + cleanup = startNative(); + } + }, + (e) => { + if (cancelled) return; + console.error("failed to load hls.js", e); + if (canPlayNative()) { + cleanup = startNative(); + } else { + video.dispatchEvent(new Event("error")); + // VODs do not use the live player's remount retries. + importRetry = setTimeout(loadHls, 2000); + } + }, + ); + }; + loadHls(); return () => { cancelled = true; + clearTimeout(importRetry); cleanup?.(); }; }, [props.url, mode, startTime]); diff --git a/js/e2e-web/flows/vod-start-time.spec.ts b/js/e2e-web/flows/vod-start-time.spec.ts index 0bc031571..ec3c9f63c 100644 --- a/js/e2e-web/flows/vod-start-time.spec.ts +++ b/js/e2e-web/flows/vod-start-time.spec.ts @@ -102,3 +102,48 @@ test("vod-start-time: ?t= starts playback partway into a VOD", async ({ ) .toBeGreaterThanOrEqual(START_SECONDS); }); + +test("vod-start-time: playback recovers after the HLS chunk fails", async ({ + page, +}) => { + test.setTimeout(10 * 60_000); + const watchPath = await uploadFixture(page); + // Chromium may advertise native HLS. Exercise browsers that need hls.js, + // while still decoding and playing the real uploaded media. + await page.addInitScript(() => { + const canPlayType = HTMLMediaElement.prototype.canPlayType; + HTMLMediaElement.prototype.canPlayType = function (type: string) { + return type === "application/vnd.apple.mpegurl" + ? "" + : canPlayType.call(this, type); + }; + }); + + let attempts = 0; + await page.route(/\/hls-[^/]+\.js$/, async (route) => { + attempts++; + if (attempts === 1) { + await route.fulfill({ status: 503, body: "Temporarily unavailable" }); + } else { + await route.continue(); + } + }); + + await page.goto(`${HTTPS_URL}${watchPath}?t=${START_SECONDS}`); + const video = page.locator("video").first(); + await expect(video).toBeVisible({ timeout: 30_000 }); + await expect.poll(() => attempts, { timeout: 30_000 }).toBeGreaterThan(1); + await expect + .poll( + () => + video.evaluate( + (element: HTMLVideoElement, startSeconds: number) => ({ + paused: element.paused, + progressed: element.currentTime > startSeconds + 1, + }), + START_SECONDS, + ), + { timeout: 60_000 }, + ) + .toEqual({ paused: false, progressed: true }); +}); -- 2.54.0 From 40944a415b51f42a11ed6cd70c84f62eaf25f4aa Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 14:38:27 -0700 Subject: [PATCH 20/76] Remove unused Ethereum addresses from stream keys --- js/app/README.md | 8 +- js/app/package.json | 1 - js/app/store/slices/baseSlice.ts | 1 - js/app/store/slices/blueskySlice.ts | 7 -- js/components/package.json | 1 - .../src/livestream-store/stream-key.tsx | 8 -- js/web/package.json | 1 - js/web/src/lib/store/slices/baseSlice.ts | 1 - js/web/src/lib/store/slices/blueskySlice.ts | 8 -- pnpm-lock.yaml | 83 ------------------- 10 files changed, 5 insertions(+), 114 deletions(-) diff --git a/js/app/README.md b/js/app/README.md index 8c2f25b17..253e4a09c 100644 --- a/js/app/README.md +++ b/js/app/README.md @@ -45,8 +45,9 @@ Suspense and error boundaries. A failed download leaves navigation usable, and retry creates a fresh lazy import instead of reusing React's cached rejection. Keep the home feed and navigation shell eager; use direct imports instead of barrels that re-export deferred screens. The broadcaster -front door also loads its stream/video screen lazily. HLS playback and stream-key -generation load their libraries only when needed. +front door also loads its stream/video screen lazily. HLS loads its playback +library only when needed. Stream keys use the existing atproto Secp256k1 keypair, +multibase private key and DID; no Ethereum address is derived or stored. HLS library downloads retry every two seconds when native HLS is unavailable, including recorded videos; leaving the player cancels the pending retry. `vod-start-time.spec.ts` checks real playback and seeking after a failed chunk. @@ -94,7 +95,8 @@ bootstrap by summing the HTML and **all** script URLs directly referenced by `.expo/atlas-web/index.html`. The `bootstrap-scripts` JSON manifest lists the runtime/common/entry scripts that download after paint: include those too when measuring bytes required for Home. Check source maps and browser network requests -to confirm HLS, stream-key crypto, and secondary screens remain deferred. +to confirm HLS and secondary screens remain deferred, and no `viem` modules +remain in the Expo graph. `.expo/atlas.jsonl` contains source code and inlined public environment variables; keep it local. Rebuild with `make app` and `make dev` before exercising embedded diff --git a/js/app/package.json b/js/app/package.json index d00a1daf1..de58ae033 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -131,7 +131,6 @@ "tus-js-client": "^4.3.1", "ua-parser-js": "^2.0.0-rc.1", "uuid": "^11.0.2", - "viem": "^2.21.44", "zustand": "^5.0.5" }, "devDependencies": { diff --git a/js/app/store/slices/baseSlice.ts b/js/app/store/slices/baseSlice.ts index cfc131b20..a3780963d 100644 --- a/js/app/store/slices/baseSlice.ts +++ b/js/app/store/slices/baseSlice.ts @@ -7,7 +7,6 @@ export const DID_KEY = "did"; export interface StreamKey { privateKey: string; did: string; - address: string; } export interface BaseSlice { diff --git a/js/app/store/slices/blueskySlice.ts b/js/app/store/slices/blueskySlice.ts index 3427fcdaf..03e37dff3 100644 --- a/js/app/store/slices/blueskySlice.ts +++ b/js/app/store/slices/blueskySlice.ts @@ -635,16 +635,9 @@ export const createBlueskySlice: StateCreator< const didBytes = new TextEncoder().encode(did); const combinedKey = new Uint8Array([...exportedKey, ...didBytes]); const multibaseKey = bytesToMultibase(combinedKey, "base58btc"); - const hexKey = Array.from(exportedKey) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - // Static import puts key-generation crypto in the initial web bundle. - const { privateKeyToAccount } = await import("viem/accounts"); - const account = await privateKeyToAccount(`0x${hexKey}`); const newKey = { privateKey: multibaseKey, did: keypair.did(), - address: account.address.toLowerCase(), }; let platform: string = Platform.OS; diff --git a/js/components/package.json b/js/components/package.json index b5c1b0856..a7e74a262 100644 --- a/js/components/package.json +++ b/js/components/package.json @@ -65,7 +65,6 @@ "react-native-webrtc": "git+https://github.com/streamplace/react-native-webrtc.git#74fa32266e3a2fee180f5e01bb8753af2a92d9d3", "react-use-websocket": "^4.13.0", "streamplace": "workspace:*", - "viem": "^2.21.44", "zustand": "^5.0.5" }, "peerDependencies": { diff --git a/js/components/src/livestream-store/stream-key.tsx b/js/components/src/livestream-store/stream-key.tsx index c3c761158..e7ebacd13 100644 --- a/js/components/src/livestream-store/stream-key.tsx +++ b/js/components/src/livestream-store/stream-key.tsx @@ -14,7 +14,6 @@ export const useStreamKey = (): { streamKey: { privateKey: string; did: string; - address: string; } | null; error: string | null; } => { @@ -43,16 +42,9 @@ export const useStreamKey = (): { const didBytes = new TextEncoder().encode(did); const combinedKey = new Uint8Array([...exportedKey, ...didBytes]); const multibaseKey = bytesToMultibase(combinedKey, "base58btc"); - const hexKey = Array.from(exportedKey) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - // Static import puts key-generation crypto in the initial web bundle. - const { privateKeyToAccount } = await import("viem/accounts"); - const account = privateKeyToAccount(`0x${hexKey}`); const newKey = { privateKey: multibaseKey, did: keypair.did(), - address: account.address.toLowerCase(), }; let platform: string = getPlatform(); diff --git a/js/web/package.json b/js/web/package.json index a3f62c462..da01b528b 100644 --- a/js/web/package.json +++ b/js/web/package.json @@ -57,7 +57,6 @@ "streamplace": "workspace:*", "tus-js-client": "^4.3.1", "tw-animate-css": "^1.4.0", - "viem": "^2.47.2", "zod": "^3.23.0", "zustand": "^5.0.5" }, diff --git a/js/web/src/lib/store/slices/baseSlice.ts b/js/web/src/lib/store/slices/baseSlice.ts index 2aab5876a..32eee688a 100644 --- a/js/web/src/lib/store/slices/baseSlice.ts +++ b/js/web/src/lib/store/slices/baseSlice.ts @@ -8,7 +8,6 @@ export const DID_KEY = "did"; export interface StreamKey { privateKey: string; did: string; - address: string; } export interface BaseSlice { diff --git a/js/web/src/lib/store/slices/blueskySlice.ts b/js/web/src/lib/store/slices/blueskySlice.ts index d5318157c..09e027053 100644 --- a/js/web/src/lib/store/slices/blueskySlice.ts +++ b/js/web/src/lib/store/slices/blueskySlice.ts @@ -490,8 +490,6 @@ export const createBlueskySlice: StateCreator< } }, - // TODO: needs @atproto/crypto + viem. The keypair code would also - // be shared with createLivestreamRecord (not yet implemented). createStreamKeyRecord: async (store: boolean) => { const state = get() as BlueskySlice; if (!state.pdsAgent) throw new Error("No agent"); @@ -500,17 +498,12 @@ export const createBlueskySlice: StateCreator< const { Secp256k1Keypair, bytesToMultibase } = await import("@atproto/crypto"); - const { privateKeyToAccount } = await import("viem/accounts"); const keypair = await Secp256k1Keypair.create({ exportable: true }); const exportedKey = await keypair.export(); const didBytes = new TextEncoder().encode(did); const combinedKey = new Uint8Array([...exportedKey, ...didBytes]); const multibaseKey = bytesToMultibase(combinedKey, "base58btc"); - const hexKey = Array.from(exportedKey) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - const account = privateKeyToAccount(`0x${hexKey}`); let userAgent = ""; if (typeof navigator !== "undefined") { @@ -535,7 +528,6 @@ export const createBlueskySlice: StateCreator< const newKey = { privateKey: multibaseKey, did: keypair.did(), - address: account.address.toLowerCase(), }; set({ newKey }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd0c7b2aa..b24c6ae2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -347,9 +347,6 @@ importers: uuid: specifier: ^11.0.2 version: 11.1.0 - viem: - specifier: ^2.21.44 - version: 2.47.2(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) zustand: specifier: ^5.0.5 version: 5.0.11(@types/react@19.2.14)(immer@11.1.8)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) @@ -540,9 +537,6 @@ importers: streamplace: specifier: workspace:* version: link:../streamplace - viem: - specifier: ^2.21.44 - version: 2.47.2(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) zustand: specifier: ^5.0.5 version: 5.0.11(@types/react@19.2.14)(immer@11.1.8)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) @@ -958,9 +952,6 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 - viem: - specifier: ^2.47.2 - version: 2.47.2(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) zod: specifier: ^3.23.0 version: 3.25.76 @@ -26561,16 +26552,6 @@ snapshots: typescript: 5.6.3 zod: 4.4.3 - abitype@1.2.3(typescript@5.9.3)(zod@3.25.76): - optionalDependencies: - typescript: 5.9.3 - zod: 3.25.76 - - abitype@1.2.3(typescript@5.9.3)(zod@4.4.3): - optionalDependencies: - typescript: 5.9.3 - zod: 4.4.3 - abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -34109,36 +34090,6 @@ snapshots: transitivePeerDependencies: - zod - ox@0.14.0(typescript@5.9.3)(zod@3.25.76): - dependencies: - '@adraffy/ens-normalize': 1.11.1 - '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.1 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) - eventemitter3: 5.0.1 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - zod - - ox@0.14.0(typescript@5.9.3)(zod@4.4.3): - dependencies: - '@adraffy/ens-normalize': 1.11.1 - '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.1 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) - eventemitter3: 5.0.1 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - zod - oxc-parser@0.127.0: dependencies: '@oxc-project/types': 0.127.0 @@ -37816,40 +37767,6 @@ snapshots: - utf-8-validate - zod - viem@2.47.2(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76): - dependencies: - '@noble/curves': 1.9.1 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) - isows: 1.0.7(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10)) - ox: 0.14.0(typescript@5.9.3)(zod@3.25.76) - ws: 8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - zod - - viem@2.47.2(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3): - dependencies: - '@noble/curves': 1.9.1 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) - isows: 1.0.7(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10)) - ox: 0.14.0(typescript@5.9.3)(zod@4.4.3) - ws: 8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - zod - vinyl-contents@2.0.0: dependencies: bl: 5.1.0 -- 2.54.0 From 1ad66ad35a8d44f73cc52f6da95b225f21dcd751 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 15:25:25 -0700 Subject: [PATCH 21/76] v0.12.5 --- js/app/package.json | 2 +- js/brand/package.json | 2 +- js/components/package.json | 2 +- js/config-react-native-webrtc/package.json | 2 +- js/core/package.json | 2 +- js/desktop/package.json | 2 +- js/dev-env/package.json | 2 +- js/docs/package.json | 2 +- js/e2e-web/package.json | 2 +- js/i18n/package.json | 2 +- js/playback-router/package.json | 2 +- js/streamplace/package.json | 2 +- js/web/package.json | 2 +- lerna.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/js/app/package.json b/js/app/package.json index d00f55aa4..b56ca252b 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -4,7 +4,7 @@ "engines": { "node": "22.x" }, - "version": "0.12.4", + "version": "0.12.5", "runtimeVersion": "0.12.0", "scripts": { "start": "npx expo start -c --port 38081", diff --git a/js/brand/package.json b/js/brand/package.json index 499259387..ffd5fcba4 100644 --- a/js/brand/package.json +++ b/js/brand/package.json @@ -1,7 +1,7 @@ { "name": "@streamplace/brand", "private": true, - "version": "0.12.4", + "version": "0.12.5", "type": "module", "scripts": { "generate": "node generate.mjs" diff --git a/js/components/package.json b/js/components/package.json index b5c1b0856..b51e5158b 100644 --- a/js/components/package.json +++ b/js/components/package.json @@ -1,6 +1,6 @@ { "name": "@streamplace/components", - "version": "0.12.4", + "version": "0.12.5", "description": "Streamplace React (Native) Components", "main": "dist/index.js", "types": "src/index.tsx", diff --git a/js/config-react-native-webrtc/package.json b/js/config-react-native-webrtc/package.json index 566f6e458..5cd9d2b0e 100644 --- a/js/config-react-native-webrtc/package.json +++ b/js/config-react-native-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@streamplace/config-react-native-webrtc", - "version": "0.12.4", + "version": "0.12.5", "description": "react-native-webrtc config mod with some changes to make it useful for streamplace", "scripts": { "prepare": "tsc -p tsconfig.json", diff --git a/js/core/package.json b/js/core/package.json index 4834ac5fa..d0f36372b 100644 --- a/js/core/package.json +++ b/js/core/package.json @@ -1,6 +1,6 @@ { "name": "@streamplace/core", - "version": "0.12.4", + "version": "0.12.5", "private": true, "description": "Streamplace platform-agnostic core (data layer, providers, i18n, theme tokens, hooks). No React Native or Expo dependencies.", "main": "./src/index.ts", diff --git a/js/desktop/package.json b/js/desktop/package.json index ef2f49039..24a458cb0 100644 --- a/js/desktop/package.json +++ b/js/desktop/package.json @@ -1,7 +1,7 @@ { "name": "streamplace-desktop", "productName": "streamplace-desktop", - "version": "0.12.4", + "version": "0.12.5", "description": "Streamplace Desktop Application", "main": ".webpack/main", "scripts": { diff --git a/js/dev-env/package.json b/js/dev-env/package.json index 62078ce2b..72345bb9b 100644 --- a/js/dev-env/package.json +++ b/js/dev-env/package.json @@ -2,7 +2,7 @@ "private": true, "type": "module", "name": "@atcute/internal-dev-env", - "version": "0.12.4", + "version": "0.12.5", "dependencies": { "@atproto/crypto": "^0.4.5", "@atproto/identity": "^0.4.12", diff --git a/js/docs/package.json b/js/docs/package.json index f0f73bd8f..e3fecac21 100644 --- a/js/docs/package.json +++ b/js/docs/package.json @@ -1,7 +1,7 @@ { "name": "streamplace-docs", "type": "module", - "version": "0.12.4", + "version": "0.12.5", "scripts": { "dev": "astro dev --host 0.0.0.0 --port 38082", "start": "astro dev --host 0.0.0.0 --port 38082", diff --git a/js/e2e-web/package.json b/js/e2e-web/package.json index 94371d725..ffb141530 100644 --- a/js/e2e-web/package.json +++ b/js/e2e-web/package.json @@ -1,6 +1,6 @@ { "name": "@streamplace/e2e-web", - "version": "0.12.4", + "version": "0.12.5", "private": true, "description": "Headless-web e2e flows (Playwright) — the web counterpart to the .maestro/ mobile flows", "scripts": { diff --git a/js/i18n/package.json b/js/i18n/package.json index ae5ed7f54..5a36a38dc 100644 --- a/js/i18n/package.json +++ b/js/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@streamplace/i18n", - "version": "0.12.4", + "version": "0.12.5", "private": true, "description": "Streamplace i18n: locale data, manifest, Fluent sources, compiled translations, and platform-agnostic config.", "main": "./src/index.ts", diff --git a/js/playback-router/package.json b/js/playback-router/package.json index d0d7e7f5e..64f14a551 100644 --- a/js/playback-router/package.json +++ b/js/playback-router/package.json @@ -1,6 +1,6 @@ { "name": "@streamplace/playback-router", - "version": "0.12.4", + "version": "0.12.5", "private": true, "scripts": { "build": "wrangler deploy --dry-run --outdir=dist", diff --git a/js/streamplace/package.json b/js/streamplace/package.json index 5fa9569ee..b97b5f7ca 100644 --- a/js/streamplace/package.json +++ b/js/streamplace/package.json @@ -1,6 +1,6 @@ { "name": "streamplace", - "version": "0.12.4", + "version": "0.12.5", "description": "Live video on the AT Protocol", "type": "module", "main": "dist/index.js", diff --git a/js/web/package.json b/js/web/package.json index a3f62c462..867efa852 100644 --- a/js/web/package.json +++ b/js/web/package.json @@ -1,7 +1,7 @@ { "name": "@streamplace/web", "private": true, - "version": "0.12.4", + "version": "0.12.5", "type": "module", "description": "Streamplace web app (Vite + TanStack Router). True-DOM, no React Native.", "scripts": { diff --git a/lerna.json b/lerna.json index 965d00dbf..3dab75214 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "0.12.4", + "version": "0.12.5", "npmClient": "pnpm" } -- 2.54.0 From eb4d868930cb3543ecab6a32837d023d01f24855 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 16:23:32 -0700 Subject: [PATCH 22/76] Fix compact web player volume visibility and cover responsive boundaries --- js/app/components/mobile/player.tsx | 4 +- js/app/components/mobile/ui.tsx | 4 + js/e2e-web/README.md | 10 ++ js/e2e-web/flows/player-breakpoints.spec.ts | 116 ++++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 js/e2e-web/flows/player-breakpoints.spec.ts diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx index 7cd2347c2..de869d07b 100644 --- a/js/app/components/mobile/player.tsx +++ b/js/app/components/mobile/player.tsx @@ -127,7 +127,9 @@ function PlayerWithProvider( ? (videoBoxHeight ?? 0) + safeTop + (Platform.OS !== "web" ? Math.max(0, chromeFade.value * 68 - 12) : 0) - : screenHeight, + : Platform.OS === "web" + ? "100%" + : screenHeight, })); const websocketConnected = useLivestreamStore((x) => x.websocketConnected); diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx index 389014c7b..85c61ec15 100644 --- a/js/app/components/mobile/ui.tsx +++ b/js/app/components/mobile/ui.tsx @@ -319,6 +319,10 @@ export function MobileUi({ /> {shouldShowChatSidePanel && setShowChat && ( { setShowChat(!showChat); }} diff --git a/js/e2e-web/README.md b/js/e2e-web/README.md index 036620ba5..08a19346a 100644 --- a/js/e2e-web/README.md +++ b/js/e2e-web/README.md @@ -64,6 +64,16 @@ live video sits above chat. It reveals the player chrome, exercises mute and fullscreen entry/exit, and verifies that faded controls reveal instead of accepting an unseen tap. +`player-breakpoints` exercises live playback at 23 widths in portrait and +landscape, including 1080 × 722 and immediately below, at, and above the theme +breakpoints (640, 768, 1024, 1280, 1536), sidebar boundary (981), and desktop +player boundary (1201). It checks that the mute button and volume slider are +inside the viewport and change the actual video element's audio state. Side-chat +layouts repeat those checks with chat open, closed, and reopened. This catches +controls clipped below the web shell's header: the compact player's height must +fill its available parent, not the whole browser window. These controls are +web-only; native live playback uses device volume controls. + `auto-publish-vods` saves and reloads the automatic publishing preference, checks that the stream form's Create tab and Privacy & Security share it, including the popout form, and hides the control without VOD beta access or diff --git a/js/e2e-web/flows/player-breakpoints.spec.ts b/js/e2e-web/flows/player-breakpoints.spec.ts new file mode 100644 index 000000000..a0fc834ac --- /dev/null +++ b/js/e2e-web/flows/player-breakpoints.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "@playwright/test"; + +// Web-only: native live playback uses the device's hardware volume controls. +// Cover both sides of theme, sidebar (981), and desktop-player (1201) boundaries. +const widths = [ + 1080, 320, 639, 640, 641, 767, 768, 769, 980, 981, 982, 1023, 1024, 1025, + 1199, 1200, 1201, 1279, 1280, 1281, 1535, 1536, 1537, +]; + +for (const orientation of ["portrait", "landscape"] as const) { + test(`player-breakpoints: ${orientation} resize boundaries`, async ({ + page, + }, testInfo) => { + test.setTimeout(600_000); + const viewport = (width: number) => ({ + width, + height: + orientation === "portrait" + ? width + 200 + : width === 1080 + ? 722 + : Math.min(568, Math.round((width * 3) / 4)), + }); + await page.setViewportSize(viewport(widths[0])); + await page.goto("/"); + await page.getByTestId("home-stream-card").first().click(); + const video = page.locator("video").first(); + await expect(video).toBeVisible(); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.readyState), + ) + .toBeGreaterThanOrEqual(2); + + const revealControls = async () => { + const box = await video.boundingBox(); + if (!box) throw new Error("player video has no bounding box"); + await page.mouse.move(0, 0); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + }; + + const exerciseVolume = async () => { + await revealControls(); + const mute = page.getByRole("button", { name: "Mute", exact: true }); + await expect(mute).toBeInViewport(); + await mute.click({ timeout: 5_000 }); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.muted), + ) + .toBe(true); + const unmute = page.getByRole("button", { + name: "Unmute", + exact: true, + }); + await unmute.hover(); + const slider = page.getByRole("slider"); + await expect(slider).toBeInViewport(); + await unmute.click(); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.muted), + ) + .toBe(false); + await slider.focus(); + await slider.press("Home"); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.muted), + ) + .toBe(true); + await slider.press("ArrowRight"); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.volume), + ) + .toBeGreaterThan(0); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.muted), + ) + .toBe(false); + }; + + for (const width of widths) { + const size = viewport(width); + await test.step(`${size.width}x${size.height}`, async () => { + await page.setViewportSize(size); + await exerciseVolume(); + if (orientation === "landscape" && width >= 768) { + const collapseChat = page.getByRole("button", { + name: "Collapse chat", + exact: true, + }); + await expect(collapseChat).toBeInViewport(); + await collapseChat.click(); + await expect(collapseChat).not.toBeInViewport(); + await exerciseVolume(); + await revealControls(); + await page + .getByRole("button", { name: "Show chat", exact: true }) + .click(); + await expect(collapseChat).toBeInViewport(); + await exerciseVolume(); + } + if (width === 1080 && orientation === "landscape") { + await revealControls(); + await testInfo.attach("1080x722-chat-open", { + body: await page.screenshot(), + contentType: "image/png", + }); + } + }); + } + }); +} -- 2.54.0 From 64e12214ff7d58b2a247bf2e8dafb5af8483e1ab Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 16:41:54 -0700 Subject: [PATCH 23/76] Require complete volume-control visibility in breakpoint coverage --- js/app/components/mobile/desktop-ui/volume-slider.tsx | 1 + js/e2e-web/README.md | 6 +++--- js/e2e-web/flows/player-breakpoints.spec.ts | 8 ++++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/js/app/components/mobile/desktop-ui/volume-slider.tsx b/js/app/components/mobile/desktop-ui/volume-slider.tsx index bc95106c9..11d97be2c 100644 --- a/js/app/components/mobile/desktop-ui/volume-slider.tsx +++ b/js/app/components/mobile/desktop-ui/volume-slider.tsx @@ -102,6 +102,7 @@ export function VolumeSlider({ sliderWidth = 200 }: { sliderWidth?: number }) { asChild > { await revealControls(); const mute = page.getByRole("button", { name: "Mute", exact: true }); - await expect(mute).toBeInViewport(); + await expect(mute).toBeInViewport({ ratio: 1 }); await mute.click({ timeout: 5_000 }); await expect .poll(() => @@ -53,9 +53,13 @@ for (const orientation of ["portrait", "landscape"] as const) { name: "Unmute", exact: true, }); + await expect(unmute).toBeInViewport({ ratio: 1 }); await unmute.hover(); const slider = page.getByRole("slider"); - await expect(slider).toBeInViewport(); + await expect(slider).toBeInViewport({ ratio: 1 }); + await expect(page.getByTestId("player-volume-track")).toBeInViewport({ + ratio: 1, + }); await unmute.click(); await expect .poll(() => -- 2.54.0 From dd28158da0f6f4219aa87d74892c5ea3814e4c75 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 16:45:46 -0700 Subject: [PATCH 24/76] player: keep the web video alive across layout changes Crossing a breakpoint moves the player to another branch of the stream page's layout, which remounted the
+ {[...contents].map(([store, content]) => { + const h = hosted.get(store); + return h && createPortal(content, h.container, h.key); + })} + + ); +} + +// Shows the current player's video, rendered by the VideoHostProvider when +// the app has one, or in place when it doesn't. +export function HostedVideo({ children }: { children: ReactNode }) { + const host = useContext(VideoHostContext); + const players = usePlayerContext(); + const store = players.players[Object.keys(players.players)[0]]; + const box = useRef(null); + + useLayoutEffect(() => { + if (!host) return; + const el = box.current!; + host.attach(store, el); + return () => host.detach(store, el); + }, [host, store]); + + useLayoutEffect(() => { + // The host is above this player's PlayerProvider. + host?.render( + store, + + {children} + , + ); + }, [host, store, players, children]); + + if (!host) return <>{children}; + return
; +} diff --git a/js/e2e-web/flows/stream.spec.ts b/js/e2e-web/flows/stream.spec.ts index f9739f274..a287fb364 100644 --- a/js/e2e-web/flows/stream.spec.ts +++ b/js/e2e-web/flows/stream.spec.ts @@ -97,3 +97,52 @@ test("stream: portrait player exposes playback controls", async ({ page }) => { .toBe(false); await expect(page.getByLabel("Enter fullscreen")).toBeVisible(); }); + +// Crossing the desktop/mobile breakpoint moves the player to another branch +// of the layout. The video has to survive that: same element, never re-sourced +// ("emptied" fires when its src or srcObject is reset), still playing. +test("stream: resizing the window keeps the same playing video", async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/"); + + const card = page.getByTestId("home-stream-card").first(); + await expect(card).toBeVisible({ timeout: 30_000 }); + await card.click(); + + const video = page.locator("video").first(); + await expect + .poll(() => + video.evaluate((v: HTMLVideoElement) => !v.paused && v.readyState >= 2), + ) + .toBe(true); + await video.evaluate((v: HTMLVideoElement) => { + const w = window as any; + w.__spVideo = v; + w.__spEmptied = 0; + v.addEventListener("emptied", () => w.__spEmptied++); + }); + + const videoWidth = () => + page.evaluate( + () => document.querySelector("video")?.getBoundingClientRect().width, + ); + for (const width of [900, 1280]) { + const oldWidth = await videoWidth(); + await page.setViewportSize({ width, height: 720 }); + // The layout has caught up once the video's box changes. + await expect.poll(videoWidth).not.toBe(oldWidth); + const state = await page.evaluate(() => { + const w = window as any; + const v = document.querySelector("video")!; + return { same: v === w.__spVideo, emptied: w.__spEmptied }; + }); + expect(state).toEqual({ same: true, emptied: 0 }); + await expect(video).toBeVisible(); + const before = await video.evaluate((v: HTMLVideoElement) => v.currentTime); + await expect + .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime)) + .toBeGreaterThan(before); + } +}); -- 2.54.0 From b4bd64fdb7bed5acad021eb0a283514a2d2c84f8 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 17:30:05 -0700 Subject: [PATCH 25/76] player: keep the playback session and stores with the hosted video Player remounts with the layout even though the video now doesn't. Its cleanup cleared the streamer's playback session, which changed the HLS URL and restarted hls.js, so usePlaybackSession now runs next to the video (inside the hosted subtree on web, in Fullscreen on native). The hosted subtree also needs the livestream and video stores (the webcam ingest reads the stream key from the livestream store), and a remounted ingest Player no longer resets a live connection's state to "new". The resize e2e now compares the video's src/srcObject rather than counting emptied events, and covers the streamer's own HLS playback. --- .../mobile-player/fullscreen.native.tsx | 2 + .../components/mobile-player/fullscreen.tsx | 9 +++ .../mobile-player/playback-session.tsx | 50 +++++++++++++ .../src/components/mobile-player/player.tsx | 52 ++------------ .../components/mobile-player/video-host.tsx | 14 +++- js/e2e-web/flows/stream.spec.ts | 70 +++++++++++++------ 6 files changed, 127 insertions(+), 70 deletions(-) create mode 100644 js/components/src/components/mobile-player/playback-session.tsx diff --git a/js/components/src/components/mobile-player/fullscreen.native.tsx b/js/components/src/components/mobile-player/fullscreen.native.tsx index 78cbbd56b..a584cd3ee 100644 --- a/js/components/src/components/mobile-player/fullscreen.native.tsx +++ b/js/components/src/components/mobile-player/fullscreen.native.tsx @@ -22,6 +22,7 @@ import { VideoRetry, } from "../.."; import { colors, surfaces } from "../../lib/theme/tokens"; +import { usePlaybackSession } from "./playback-session"; import { AudioOnlyOverlay } from "./ui/audio-only-overlay"; import Video from "./video.native"; @@ -31,6 +32,7 @@ export function Fullscreen(props: { objectFit?: "contain" | "cover"; pictureInPictureEnabled?: boolean; }) { + usePlaybackSession(props.src); const ref = useRef(null); const insets = useSafeAreaInsets(); const dimensions = useWindowDimensions(); diff --git a/js/components/src/components/mobile-player/fullscreen.tsx b/js/components/src/components/mobile-player/fullscreen.tsx index faeedda4f..b877ef0de 100644 --- a/js/components/src/components/mobile-player/fullscreen.tsx +++ b/js/components/src/components/mobile-player/fullscreen.tsx @@ -11,6 +11,7 @@ import { usePlayerStore, } from "../.."; import { View } from "../../components/ui"; +import { usePlaybackSession } from "./playback-session"; import { AudioOnlyOverlay } from "./ui/audio-only-overlay"; import Video from "./video"; import { HostedVideo } from "./video-host"; @@ -125,6 +126,7 @@ export function Fullscreen(props: { style={{ width: "100%", height: "100%", overflow: "hidden" }} > +
; diff --git a/js/e2e-web/flows/stream.spec.ts b/js/e2e-web/flows/stream.spec.ts index a287fb364..e184cf7d2 100644 --- a/js/e2e-web/flows/stream.spec.ts +++ b/js/e2e-web/flows/stream.spec.ts @@ -1,4 +1,5 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; +import { loginThroughPds } from "./login"; // Mirror of .maestro/logged-out/stream.yaml: open the looping test stream from the home // feed and confirm playback context loads. The home feed card carries @@ -99,18 +100,10 @@ test("stream: portrait player exposes playback controls", async ({ page }) => { }); // Crossing the desktop/mobile breakpoint moves the player to another branch -// of the layout. The video has to survive that: same element, never re-sourced -// ("emptied" fires when its src or srcObject is reset), still playing. -test("stream: resizing the window keeps the same playing video", async ({ - page, -}) => { - await page.setViewportSize({ width: 1280, height: 720 }); - await page.goto("/"); - - const card = page.getByTestId("home-stream-card").first(); - await expect(card).toBeVisible({ timeout: 30_000 }); - await card.click(); - +// of the layout. The video has to survive that: same element, still playing +// what it was (a WebRTC srcObject, or a src, which hls.js sets to a new blob: +// URL on the element or a child each time it starts over). +async function expectVideoSurvivesResize(page: Page) { const video = page.locator("video").first(); await expect .poll(() => @@ -118,10 +111,8 @@ test("stream: resizing the window keeps the same playing video", async ({ ) .toBe(true); await video.evaluate((v: HTMLVideoElement) => { - const w = window as any; - w.__spVideo = v; - w.__spEmptied = 0; - v.addEventListener("emptied", () => w.__spEmptied++); + const src = v.src + (v.querySelector("source")?.src ?? ""); + (window as any).__spVideo = { el: v, src, srcObject: v.srcObject }; }); const videoWidth = () => @@ -134,15 +125,54 @@ test("stream: resizing the window keeps the same playing video", async ({ // The layout has caught up once the video's box changes. await expect.poll(videoWidth).not.toBe(oldWidth); const state = await page.evaluate(() => { - const w = window as any; + const before = (window as any).__spVideo; const v = document.querySelector("video")!; - return { same: v === w.__spVideo, emptied: w.__spEmptied }; + const src = v.src + (v.querySelector("source")?.src ?? ""); + return { + sameElement: v === before.el, + sameSource: src === before.src && v.srcObject === before.srcObject, + }; }); - expect(state).toEqual({ same: true, emptied: 0 }); + expect(state).toEqual({ sameElement: true, sameSource: true }); await expect(video).toBeVisible(); const before = await video.evaluate((v: HTMLVideoElement) => v.currentTime); await expect .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime)) .toBeGreaterThan(before); } +} + +test("stream: resizing the window keeps the same playing video", async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/"); + const card = page.getByTestId("home-stream-card").first(); + await expect(card).toBeVisible({ timeout: 30_000 }); + await card.click(); + await expectVideoSurvivesResize(page); +}); + +// The streamer watching their own stream over HLS plays it with their own +// playback session, which is part of the playlist URL. A layout change must +// not drop it, or the URL changes and hls.js starts over. +test("stream: the streamer's own HLS video survives a resize", async ({ + page, +}) => { + const httpsUrl = process.env.SERVER_HTTPS_URL; + test.skip(!httpsUrl, "harness started without its HTTPS hostnames"); + await page.setViewportSize({ width: 1280, height: 720 }); + await loginThroughPds(page); + await page.evaluate(() => localStorage.setItem("player-protocol", "hls")); + + await page.goto(`${httpsUrl}/`); + // The player's own master playlist URL, once it has the session (the + // node's variant playlists carry a sid of their own). hls.js attaches its + // new source in the same task as this request. + const sessionPlaylist = page.waitForRequest((r) => + /getLivePlaylist\?streamer=[^&]+&sid=/.test(r.url()), + ); + await page.getByTestId("home-stream-card").first().click(); + await sessionPlaylist; + await expectVideoSurvivesResize(page); }); -- 2.54.0 From 851f9db6a55b2bf19aa46f00b84b070c9a3f010b Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 17:43:22 -0700 Subject: [PATCH 26/76] Wait for video remounts while testing responsive player transitions --- js/e2e-web/flows/player-breakpoints.spec.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/js/e2e-web/flows/player-breakpoints.spec.ts b/js/e2e-web/flows/player-breakpoints.spec.ts index b8396aa7e..e36261315 100644 --- a/js/e2e-web/flows/player-breakpoints.spec.ts +++ b/js/e2e-web/flows/player-breakpoints.spec.ts @@ -25,18 +25,21 @@ for (const orientation of ["portrait", "landscape"] as const) { await page.goto("/"); await page.getByTestId("home-stream-card").first().click(); const video = page.locator("video").first(); - await expect(video).toBeVisible(); - await expect - .poll(() => - video.evaluate((element: HTMLVideoElement) => element.readyState), - ) - .toBeGreaterThanOrEqual(2); const revealControls = async () => { - const box = await video.boundingBox(); - if (!box) throw new Error("player video has no bounding box"); await page.mouse.move(0, 0); - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + // Resizing can remount the video. Wait for its replacement's geometry; + // the player overlay, not the video element, receives the pointer. + await expect(async () => { + const box = await video.boundingBox(); + if (!box) throw new Error("player video has no bounding box"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + }).toPass({ timeout: 30_000 }); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.readyState), + ) + .toBeGreaterThanOrEqual(2); }; const exerciseVolume = async () => { -- 2.54.0 From 457873ee19836b1a17eb4513e203779a8be9d22a Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 18:26:33 -0700 Subject: [PATCH 27/76] components: stub usePlaybackSession in the portrait player test The native Fullscreen now calls it, and this test renders Fullscreen without a StreamplaceProvider, stubbing its other store-backed pieces. --- js/components/tests/portrait-player.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/js/components/tests/portrait-player.test.tsx b/js/components/tests/portrait-player.test.tsx index a4721533a..cd733cdf3 100644 --- a/js/components/tests/portrait-player.test.tsx +++ b/js/components/tests/portrait-player.test.tsx @@ -187,6 +187,9 @@ vi.mock("../src/components/mobile-player/video.native", () => ({ vi.mock("../src/components/mobile-player/ui/audio-only-overlay", () => ({ AudioOnlyOverlay: () => null, })); +vi.mock("../src/components/mobile-player/playback-session", () => ({ + usePlaybackSession: () => {}, +})); async function bindings() { const atoms = await import("../src/lib/theme/atoms"); -- 2.54.0 From 736fb60836db16da84c23984cdb6238e45722068 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 14 Oct 2026 04:44:08 -0700 Subject: [PATCH 28/76] Upgrade media pipelines to generated go-gst bindings --- docs/go-conventions.md | 31 ++- go.mod | 10 +- go.sum | 10 +- pkg/cmd/duplicate_mist_test.go | 11 +- pkg/cmd/streamplace.go | 4 +- pkg/cmd/whep.go | 99 ---------- pkg/cmd/whip.go | 70 +++---- pkg/gstinit/gstinit.go | 4 +- pkg/media/bus_handler.go | 69 ++----- pkg/media/bus_handler_test.go | 53 +++-- pkg/media/concat_demux.go | 138 ++++++------- pkg/media/concat_demux_test.go | 58 +++--- pkg/media/gstreamer.go | 205 ++++++++++---------- pkg/media/ingest_worker.go | 10 +- pkg/media/ingest_worker_test.go | 34 ++-- pkg/media/io_helpers.go | 60 +++--- pkg/media/io_helpers_test.go | 52 +++-- pkg/media/key_revocation.go | 6 +- pkg/media/keyframe_probe.go | 14 +- pkg/media/leak/leak.go | 19 +- pkg/media/media_data_parser.go | 172 +++++++--------- pkg/media/mist_mp4_ingest_test.go | 44 +++-- pkg/media/mp4_ingest.go | 54 +++--- pkg/media/muxl_segment.go | 53 +++-- pkg/media/packetize.go | 102 +++++----- pkg/media/packetize_test.go | 61 +++--- pkg/media/random_access_src.go | 49 ++--- pkg/media/random_access_src_test.go | 50 +++-- pkg/media/rtmp_ingest.go | 49 +++-- pkg/media/rtmp_push.go | 80 ++++---- pkg/media/segment_conv.go | 226 +++++++++++----------- pkg/media/segmenter.go | 137 ++++++------- pkg/media/thumbnail.go | 111 ++++++----- pkg/media/transcode.go | 100 +++++----- pkg/media/transcode_stream.go | 32 +-- pkg/media/vod_pipeline.go | 198 ++++++++----------- pkg/media/webrtc_ingest.go | 107 +++++----- pkg/media/whip_worker_test.go | 24 +-- pkg/spxrpc/com_atproto_moderation_test.go | 25 +-- 39 files changed, 1195 insertions(+), 1436 deletions(-) diff --git a/docs/go-conventions.md b/docs/go-conventions.md index cb5d81a82..06f2aacc9 100644 --- a/docs/go-conventions.md +++ b/docs/go-conventions.md @@ -98,10 +98,27 @@ requesting the native snapshot; keep that barrier synchronous and never block th finalizer queue, which also releases Go-owned GStreamer references. Verify changes to the helper with race-enabled media tests without disabling leak assertions. -`WriterNewSample` releases each appsink sample when its callback returns, after -unmapping its buffer. The go-gst v1.4 `GstSample` wrapper contains only a pointer -to an opaque C type; on amd64 it is an 8-byte, GC-pointer-free allocation. Go's -tiny allocator can pack it beside another live object, preventing its finalizer -from running even after repeated GC. Clear the binding's finalizer before -explicitly releasing that transferred reference, or it may be released twice. -This is sample ownership, not a requirement to wrap every `Buffer.Map` call. +## GStreamer bindings + +Use the generated `go-gst/pkg/gst` and `go-gst/pkg/gstapp` APIs. GObjects such as +elements, pads, bins, and pipelines are interfaces. Check boolean link/add results +and `StateChangeReturn` values rather than treating them as Go errors. + +GObject properties require fixed-width numeric values: `int32` for `gint`, +`uint32` for `guint`, and `uint64` for `guint64`. The new GValue conversion rejects +Go `int` and `uint`. + +`MapInfo.Data()` is a view of native memory, valid only until `MapInfo.Unmap()`. +Synchronous writers may consume it directly. Use `copyBufferData` when bytes +escape into queues or caches; Pion's H264 payloader can retain SPS/PPS slices +between calls. Use `newBufferFromBytes` for writable, Go-sourced payloads. + +Sample buffers and caps are borrowed getters that retain their sample through +the binding's GC ownership mechanism. Do not explicitly unref a sample while its +borrowed children remain in use. The bindings now handle sample finalization; +callers must not install their own finalizers or repeat the old sample workaround. + +The published dependency pins include the opaque-owner tiny-allocation fix: +[go-glib #37](https://github.com/go-gst/go-glib/pull/37) and +[go-gst #228](https://github.com/go-gst/go-gst/pull/228). Remove the remote fork +replacements when an upstream release contains both fixes. diff --git a/go.mod b/go.mod index 2db94e391..52f5fe52a 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,11 @@ replace github.com/AxisCommunications/go-dpop => github.com/streamplace/go-dpop replace github.com/bluesky-social/indigo => github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 +// Opaque-owner finalization fixes: go-gst/go-glib#37 and go-gst/go-gst#228. +replace github.com/go-gst/go-glib => github.com/iameli/go-glib v0.0.4-0.20261013211756-2f69802326f8 + +replace github.com/go-gst/go-gst => github.com/iameli/go-gst v0.0.3-0.20261014114126-91d7afbbeddc + tool github.com/bluesky-social/indigo/cmd/lexgen tool github.com/streamplace/glex/cmd/glex @@ -35,8 +40,8 @@ require ( github.com/ethereum/go-ethereum v1.16.2 github.com/fxamacker/cbor/v2 v2.9.2 github.com/go-git/go-git/v5 v5.12.0 - github.com/go-gst/go-glib v1.4.0 - github.com/go-gst/go-gst v1.4.0 + github.com/go-gst/go-glib v0.0.3 + github.com/go-gst/go-gst v0.0.3-0.20261013180519-636b86bce9e8 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/golang/glog v1.2.5 @@ -396,7 +401,6 @@ require ( github.com/maratori/testpackage v1.1.1 // indirect github.com/matoous/godox v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-pointer v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mgechev/revive v1.9.0 // indirect github.com/mholt/acmez/v3 v3.1.6 // indirect diff --git a/go.sum b/go.sum index b87120386..3dac118fe 100644 --- a/go.sum +++ b/go.sum @@ -493,10 +493,6 @@ github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXY github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gst/go-glib v1.4.0 h1:FB2uVfB0uqz7/M6EaDdWWlBZRQpvFAbWfL7drdw8lAE= -github.com/go-gst/go-glib v1.4.0/go.mod h1:GUIpWmkxQ1/eL+FYSjKpLDyTZx6Vgd9nNXt8dA31d5M= -github.com/go-gst/go-gst v1.4.0 h1:EikB43u4c3wc8d2RzlFRSfIGIXYzDy6Zls2vJqrG2BU= -github.com/go-gst/go-gst v1.4.0/go.mod h1:p8TLGtOxJLcrp6PCkTPdnanwWBxPZvYiHDbuSuwgO3c= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -763,6 +759,10 @@ github.com/hyphacoop/cbor/v2 v2.0.0-20251007204234-2a4fa83e606e h1:4HqTNG0M8I/MY github.com/hyphacoop/cbor/v2 v2.0.0-20251007204234-2a4fa83e606e/go.mod h1:1ny0WdocVllO4iUxV5eXuJ9vMzmZ2nITeZg7uBxVEeU= github.com/hyphacoop/go-dasl v0.8.0 h1:0K2nvGLUb8aBqG8GGUDbMgqCG9IVPT38hnpCVpzMCIU= github.com/hyphacoop/go-dasl v0.8.0/go.mod h1:6Z8cAEAsv495mgxatpgIdEcJxDBc7Nbg/ZJX58iGJfU= +github.com/iameli/go-glib v0.0.4-0.20261013211756-2f69802326f8 h1:c7x+XAmWf5mcSLHO4WZwM/L9S54f6uRv58iDfR4SkUM= +github.com/iameli/go-glib v0.0.4-0.20261013211756-2f69802326f8/go.mod h1:cHWRWZCiwhx4CmW92T0/+cxn69AquI7aj2mIk96WgEI= +github.com/iameli/go-gst v0.0.3-0.20261014114126-91d7afbbeddc h1:KNn25w/ExcTFALcK7KnoZ4ZmVK3TPA8AGO64L8zfHFo= +github.com/iameli/go-gst v0.0.3-0.20261014114126-91d7afbbeddc/go.mod h1:NPMsDZ4Bq3TzTWxUYMYnOyclGNHQUEV9EcbUc6jN7cM= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -1028,8 +1028,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= -github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= diff --git a/pkg/cmd/duplicate_mist_test.go b/pkg/cmd/duplicate_mist_test.go index 31e90548e..ade774f16 100644 --- a/pkg/cmd/duplicate_mist_test.go +++ b/pkg/cmd/duplicate_mist_test.go @@ -22,7 +22,7 @@ import ( "github.com/bluenviron/gortmplib/pkg/bytecounter" "github.com/bluenviron/gortmplib/pkg/message" "github.com/bluenviron/gortsplib/v5/pkg/format" - "github.com/go-gst/go-gst/gst" + "github.com/go-gst/go-gst/pkg/gst" "github.com/prometheus/client_golang/prometheus/promhttp" scraper "github.com/starttoaster/prometheus-exporter-scraper" "github.com/stretchr/testify/require" @@ -309,22 +309,23 @@ func TestDuplicateMistEndToEnd(t *testing.T) { }() gstinit.InitGST() - pipeline, err := gst.NewPipelineFromString(fmt.Sprintf( + element, err := gst.ParseLaunch(fmt.Sprintf( "flvmux name=mux streamable=true ! rtmp2sink sync=%t location=rtmp://%s%s "+ "videotestsrc num-buffers=300 ! video/x-raw,width=320,height=240,framerate=30/1 ! x264enc key-int-max=30 bframes=0 tune=zerolatency ! h264parse ! queue ! mux.video "+ "audiotestsrc num-buffers=470 samplesperbuffer=1024 ! audio/x-raw,rate=48000 ! audioconvert ! fdkaacenc ! aacparse ! queue ! mux.audio", paced, bridge.Addr(), publisherPath)) require.NoError(t, err) - defer pipeline.SetState(gst.StateNull) //nolint:errcheck + pipeline := element.(gst.Pipeline) + defer pipeline.SetState(gst.StateNull) busDone := make(chan error, 1) go func() { busDone <- media.HandleBusMessages(ctx, pipeline) }() - require.NoError(t, pipeline.SetState(gst.StatePlaying)) + require.NotEqual(t, gst.StateChangeFailure, pipeline.SetState(gst.StatePlaying)) select { case err := <-busDone: require.NoError(t, err) case <-ctx.Done(): t.Fatal("publisher timed out") } - require.NoError(t, pipeline.SetState(gst.StateNull)) + require.Equal(t, gst.StateChangeSuccess, pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone)) if paced { // The TLS publisher has not sent EOF; progress cannot be deferred // until worker completion. Encoding speed is irrelevant. diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index e13d8db02..b397cdd87 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -58,8 +58,8 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" awss3 "github.com/aws/aws-sdk-go-v2/service/s3" - _ "github.com/go-gst/go-glib/glib" - _ "github.com/go-gst/go-gst/gst" + _ "github.com/go-gst/go-glib/pkg/glib/v2" + _ "github.com/go-gst/go-gst/pkg/gst" "stream.place/streamplace/pkg/api" "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/model" diff --git a/pkg/cmd/whep.go b/pkg/cmd/whep.go index 3a9bd8c7e..3d7b989e3 100644 --- a/pkg/cmd/whep.go +++ b/pkg/cmd/whep.go @@ -268,105 +268,6 @@ func (w *WHEPClient) WHEP(ctx context.Context) error { if err != nil { return err } - // if err := g.Wait(); err != nil { - // if err := g.Wait(); err != nil { - // return err - // } - // // Start a ticker to print elapsed duration every second - // go func() { - // ticker := time.NewTicker(time.Second) - // defer ticker.Stop() - - // for { - // <-ticker.C - // for i, duration := range accumulators { - // trackType := "video" - // if i == 1 { - // trackType = "audio" - // } - // target := startTime.Add(time.Duration(accumulators[i])) - // diff := time.Since(target) - // log.Debug(ctx, "elapsed duration", "track", trackType, "duration", duration, "diff", diff) - // } - // } - // }() - - // errCh := make(chan error, 1) - - // for i, _ := range sinks { - // func(i int) { - // sink := sinks[i] - // trackType := "video" - // if i == 1 { - // trackType = "audio" - // } - - // sink.SetCallbacks(&app.SinkCallbacks{ - // NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { - - // sample := sink.PullSample() - // if sample == nil { - // return gst.FlowEOS - // } - - // buffer := sample.GetBuffer() - // if buffer == nil { - // return gst.FlowError - // } - - // samples := buffer.Map(gst.MapRead).Bytes() - // defer buffer.Unmap() - - // durationPtr := buffer.Duration().AsDuration() - // var duration time.Duration - // if durationPtr == nil { - // errCh <- fmt.Errorf("%v duration: nil", trackType) - // return gst.FlowError - // } else { - // // fmt.Printf("%v duration: %v\n", trackType, *durationPtr) - // duration = *durationPtr - // } - - // accumulators[i] += duration - - // if w.FreezeAfter == 0 || time.Since(startTime) < w.FreezeAfter { - // for _, conn := range conns { - // if trackType == "video" { - // if err := conn.videoTrack.WriteSample(pionmedia.Sample{Data: samples, Duration: duration}); err != nil { - // log.Log(ctx, "error writing video sample", "error", err) - // errCh <- err - // return gst.FlowError - // } - // } else { - // if err := conn.audioTrack.WriteSample(pionmedia.Sample{Data: samples, Duration: duration}); err != nil { - // log.Log(ctx, "error writing video sample", "error", err) - // errCh <- err - // return gst.FlowError - // } - // } - // } - // } - - // return gst.FlowOK - // }, - // }) - // }(i) - // } - - // go func() { - // media.HandleBusMessages(ctx, pipeline) - // cancel() - // }() - - // if err = pipeline.SetState(gst.StatePlaying); err != nil { - // return err - // } - // select { - // case err := <-errCh: - // return err - // case <-ctx.Done(): - // return ctx.Err() - // } return nil } diff --git a/pkg/cmd/whip.go b/pkg/cmd/whip.go index 0ae3c2be6..e7c824eae 100644 --- a/pkg/cmd/whip.go +++ b/pkg/cmd/whip.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/pion/webrtc/v4" pionmedia "github.com/pion/webrtc/v4/pkg/media" "golang.org/x/sync/errgroup" @@ -106,40 +106,38 @@ func (w *WHIPClient) WHIP(ctx context.Context) (retErr error) { // "audio_tee. ! mux.audio_0", } - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) + element, err := gst.ParseLaunch(strings.Join(pipelineSlice, "\n")) if err != nil { return err } + pipeline := element.(gst.Pipeline) defer func() { cancel() - if err := pipeline.BlockSetState(gst.StateNull); err != nil && retErr == nil { - retErr = err + if status := pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone); status != gst.StateChangeSuccess && retErr == nil { + retErr = fmt.Errorf("stop WHIP pipeline: %s", status) } }() - fileSrc, err := pipeline.GetElementByName("filesrc") - if err != nil { - return err + fileSrc := pipeline.GetByName("filesrc") + if fileSrc == nil { + return fmt.Errorf("filesrc not found") } + fileSrc.SetObjectProperty("location", w.File) - if err := fileSrc.Set("location", w.File); err != nil { - return err + videoSink := pipeline.GetByName("videoappsink") + if videoSink == nil { + return fmt.Errorf("video appsink not found") } - videoSink, err := pipeline.GetElementByName("videoappsink") - if err != nil { - return err - } - - audioSink, err := pipeline.GetElementByName("audioappsink") - if err != nil { - return err + audioSink := pipeline.GetByName("audioappsink") + if audioSink == nil { + return fmt.Errorf("audio appsink not found") } startTime := time.Now() - sinks := []*app.Sink{ - app.SinkFromElement(videoSink), - app.SinkFromElement(audioSink), + sinks := []app.AppSink{ + videoSink.(app.AppSink), + audioSink.(app.AppSink), } // Create accumulators for tracking elapsed duration accumulators := make([]time.Duration, len(sinks)) @@ -236,8 +234,8 @@ func (w *WHIPClient) WHIP(ctx context.Context) (retErr error) { trackType = "audio" } - sink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { + sink.SetCallbacks(&app.AppSinkCallbacks{ + NewSample: func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { @@ -249,18 +247,24 @@ func (w *WHIPClient) WHIP(ctx context.Context) (retErr error) { return gst.FlowError } - samples := buffer.Map(gst.MapRead).Bytes() - defer buffer.Unmap() + mapped, ok := buffer.Map(gst.MapRead) + if !ok { + reportError(fmt.Errorf("%s buffer mapping failed", trackType)) + return gst.FlowError + } + defer mapped.Unmap() + samples := mapped.Data() + if trackType == "video" { + // Pion may retain SPS/PPS slices between H264 samples. + samples = append([]byte(nil), samples...) + } - durationPtr := buffer.Duration().AsDuration() - var duration time.Duration - if durationPtr == nil { + clockDuration := buffer.Duration() + if clockDuration == gst.ClockTimeNone { reportError(fmt.Errorf("%v duration: nil", trackType)) return gst.FlowError - } else { - // fmt.Printf("%v duration: %v\n", trackType, *durationPtr) - duration = *durationPtr } + duration := time.Duration(clockDuration) accumulators[i] += duration @@ -295,8 +299,8 @@ func (w *WHIPClient) WHIP(ctx context.Context) (retErr error) { cancel() }() - if err = pipeline.SetState(gst.StatePlaying); err != nil { - return err + if pipeline.SetState(gst.StatePlaying) == gst.StateChangeFailure { + return fmt.Errorf("failed to start WHIP pipeline") } if w.Viewers > 0 { whepG, ctx := errgroup.WithContext(ctx) diff --git a/pkg/gstinit/gstinit.go b/pkg/gstinit/gstinit.go index 987d46ea5..5a0c6aa40 100644 --- a/pkg/gstinit/gstinit.go +++ b/pkg/gstinit/gstinit.go @@ -3,13 +3,13 @@ package gstinit import ( "sync" - "github.com/go-gst/go-gst/gst" + "github.com/go-gst/go-gst/pkg/gst" ) var initOnce sync.Once func InitGST() { initOnce.Do(func() { - gst.Init(nil) + gst.Init() }) } diff --git a/pkg/media/bus_handler.go b/pkg/media/bus_handler.go index 54d4f2dcf..43e0945ee 100644 --- a/pkg/media/bus_handler.go +++ b/pkg/media/bus_handler.go @@ -6,26 +6,26 @@ import ( "fmt" "time" - "github.com/go-gst/go-gst/gst" + "github.com/go-gst/go-gst/pkg/gst" "stream.place/streamplace/pkg/log" ) -// ErrPipelineDone is a sentinel a pipeline element can raise (via -// pipeline.Error) to signal it finished its job and the bus handler should -// exit cleanly rather than treat it as a failure — e.g. the thumbnailer once -// it has grabbed its frame. +// ErrPipelineDone is a sentinel a pipeline element can post with +// gst.NewMessageError to signal it finished its job and the bus handler should +// exit cleanly rather than treat it as a failure. var ErrPipelineDone = errors.New("pipeline done") -func HandleBusMessages(ctx context.Context, pipeline *gst.Pipeline) error { +func HandleBusMessages(ctx context.Context, pipeline gst.Pipeline) error { return HandleBusMessagesCustom(ctx, pipeline, nil) } -func HandleBusMessagesCustom(ctx context.Context, pipeline *gst.Pipeline, handler func(msg *gst.Message)) error { +func HandleBusMessagesCustom(ctx context.Context, pipeline gst.Pipeline, handler func(msg *gst.Message)) error { + bus := pipeline.GetBus() for { if ctx.Err() != nil { return ctx.Err() } - msg := pipeline.GetPipelineBus().PopMessage(gst.ClockTime(time.Second * 1)) + msg := bus.TimedPop(gst.ClockTime(time.Second)) if msg == nil { continue } @@ -37,13 +37,13 @@ func HandleBusMessagesCustom(ctx context.Context, pipeline *gst.Pipeline, handle log.Debug(ctx, "got gst.MessageEOS, exiting") return nil case gst.MessageError: // Error messages are always fatal - err := msg.ParseError() - if err.Error() == fmt.Sprintf("%s: %s", ErrPipelineDone.Error(), ErrPipelineDone.Error()) { + debug, err := msg.ParseError() + if err.Error() == ErrPipelineDone.Error() { log.Debug(ctx, "got ErrPipelineDone, exiting") return nil } log.Error(ctx, "gstreamer error", "error", err.Error()) - if debug := err.DebugString(); debug != "" { + if debug != "" { log.Debug(ctx, "gstreamer debug", "message", debug) } return fmt.Errorf("gstreamer error: %w", err) @@ -54,50 +54,3 @@ func HandleBusMessagesCustom(ctx context.Context, pipeline *gst.Pipeline, handle } } } - -// func HandleBusMessages(ctx context.Context, pipeline *gst.Pipeline) error { -// return HandleBusMessagesCustom(ctx, pipeline, nil) -// } - -// func HandleBusMessagesCustom(ctx context.Context, pipeline *gst.Pipeline, handler func(msg *gst.Message)) error { -// msgCh := make(chan *gst.Message, 1024) -// bus := pipeline.GetPipelineBus() -// bus.SetSyncHandler(func(msg *gst.Message) gst.BusSyncReply { -// if ctx.Err() != nil { -// log.Error(ctx, "context cancelled, dropping message", "message", msg.String()) -// msg.Unref() -// return gst.BusDrop -// } -// log.Error(ctx, "got message", "message", msg.String()) -// msgCh <- msg -// return gst.BusDrop -// }) -// for { -// if ctx.Err() != nil { -// return ctx.Err() -// } -// select { -// case <-ctx.Done(): -// return nil -// case msg := <-msgCh: -// if handler != nil { -// handler(msg) -// } -// switch msg.Type() { -// case gst.MessageEOS: // When end-of-stream is received flush the pipeline and stop the main loop -// log.Debug(ctx, "got gst.MessageEOS, exiting") -// return nil -// case gst.MessageError: // Error messages are always fatal -// err := msg.ParseError() -// log.Error(ctx, "gstreamer error", "error", err.Error()) -// if debug := err.DebugString(); debug != "" { -// log.Debug(ctx, "gstreamer debug", "message", debug) -// } -// return fmt.Errorf("gstreamer error: %w", err) -// default: -// log.Debug(ctx, msg.String()) -// msg.Unref() -// } -// } -// } -// } diff --git a/pkg/media/bus_handler_test.go b/pkg/media/bus_handler_test.go index 7d571db8f..8dd436a83 100644 --- a/pkg/media/bus_handler_test.go +++ b/pkg/media/bus_handler_test.go @@ -5,8 +5,8 @@ import ( "fmt" "testing" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" "stream.place/streamplace/pkg/log" @@ -36,10 +36,7 @@ func testBusHandlerCleanupInner(ctx context.Context, i int) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - pipeline, err := gst.NewPipeline(fmt.Sprintf("TestBusHandler-%d", i)) - if err != nil { - return err - } + pipeline := gst.NewPipeline(fmt.Sprintf("TestBusHandler-%d", i)).(gst.Pipeline) busDone := make(chan struct{}) go func() { @@ -51,49 +48,45 @@ func testBusHandlerCleanupInner(ctx context.Context, i int) error { defer func() { cancel() <-busDone - err = pipeline.SetState(gst.StateNull) - if err != nil { - panic(fmt.Sprintf("failed to set state to null: %s", err)) + if pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone) != gst.StateChangeSuccess { + panic("failed to set state to null") } }() - fileSrc, err := gst.NewElementWithProperties("filesrc", map[string]any{ + fileSrc := gst.ElementFactoryMakeWithProperties("filesrc", map[string]any{ "location": getFixture("5sec.mp4"), }) - if err != nil { - return err + if fileSrc == nil { + return fmt.Errorf("failed to create filesrc") } - err = pipeline.Add(fileSrc) - if err != nil { - return err + if !pipeline.Add(fileSrc) { + return fmt.Errorf("failed to add filesrc") } - demux, err := gst.NewElementWithProperties("qtdemux", map[string]any{ + demux := gst.ElementFactoryMakeWithProperties("qtdemux", map[string]any{ "name": fmt.Sprintf("TestBusHandler-qtdemux-%d", i), }) - if err != nil { - return err + if demux == nil { + return fmt.Errorf("failed to create demux") } - err = pipeline.Add(demux) - if err != nil { - return err + if !pipeline.Add(demux) { + return fmt.Errorf("failed to add demux") } - appSink, err := gst.NewElementWithProperties("appsink", map[string]any{ + appSink := gst.ElementFactoryMakeWithProperties("appsink", map[string]any{ "name": fmt.Sprintf("TestBusHandler-appsink-%d", i), "sync": false, }) - if err != nil { - return err + if appSink == nil { + return fmt.Errorf("failed to create appsink") } - err = pipeline.Add(appSink) - if err != nil { - return err + if !pipeline.Add(appSink) { + return fmt.Errorf("failed to add appsink") } - sink := app.SinkFromElement(appSink) - sink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, nil), + sink := appSink.(app.AppSink) + sink.SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, nil), }) return fmt.Errorf("test error") diff --git a/pkg/media/concat_demux.go b/pkg/media/concat_demux.go index 42cb5b527..2c3495f8d 100644 --- a/pkg/media/concat_demux.go +++ b/pkg/media/concat_demux.go @@ -7,94 +7,77 @@ import ( "strings" "sync/atomic" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "stream.place/streamplace/pkg/bus" "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/log" ) // silly technique to avoid leaking pads -func doNothing(self *gst.Element, pad *gst.Pad) {} +func doNothing(self gst.Element, pad gst.Pad) {} // doNothing for signals without a pad argument -func doNothingElement(self *gst.Element) {} +func doNothingElement(self gst.Element) {} // Function for demuxing a single segment. Needs to be handled very carefully. // In particular: users of this MUST cancel the passed context when they're // done with the bin. -func ConcatDemuxBin(ctx context.Context, seg *bus.Seg, doH264Parse bool) (*gst.Bin, error) { +func ConcatDemuxBin(ctx context.Context, seg *bus.Seg, doH264Parse bool) (gst.Bin, error) { ctx = log.WithLogValues(ctx, "func", "ConcatDemuxBin") - bin := gst.NewBin("seg-demux-bin") + bin := gst.NewBin("seg-demux-bin").(gst.Bin) - appSrc, err := gst.NewElementWithProperties("appsrc", map[string]interface{}{ + appSrc := gst.ElementFactoryMakeWithProperties("appsrc", map[string]interface{}{ "name": "concat-appsrc", }) - if err != nil { - return nil, fmt.Errorf("failed to create appsrc element: %w", err) + if appSrc == nil { + return nil, fmt.Errorf("failed to create appsrc element") } - err = bin.Add(appSrc) - if err != nil { - return nil, fmt.Errorf("failed to add appsrc to bin: %w", err) + if !bin.Add(appSrc) { + return nil, fmt.Errorf("failed to add appsrc to bin") } - demux, err := gst.NewElementWithProperties("qtdemux", map[string]interface{}{ + demux := gst.ElementFactoryMakeWithProperties("qtdemux", map[string]interface{}{ "name": "concat-demux", }) - if err != nil { - return nil, fmt.Errorf("failed to create qtdemux element: %w", err) + if demux == nil { + return nil, fmt.Errorf("failed to create qtdemux element") } - err = bin.Add(demux) - if err != nil { - return nil, fmt.Errorf("failed to add qtdemux to bin: %w", err) + if !bin.Add(demux) { + return nil, fmt.Errorf("failed to add qtdemux to bin") } - err = appSrc.Link(demux) - if err != nil { - return nil, fmt.Errorf("failed to link appsrc to qtdemux: %w", err) + if !appSrc.Link(demux) { + return nil, fmt.Errorf("failed to link appsrc to qtdemux") } - tmpl := demux.GetPadTemplates() + tmpl := demux.GetPadTemplateList() if tmpl == nil { return nil, fmt.Errorf("pad templates not found") } - mq, err := gst.NewElementWithProperties("multiqueue", map[string]interface{}{ + mq := gst.ElementFactoryMakeWithProperties("multiqueue", map[string]interface{}{ "name": "concat-demux-multiqueue", - "max-size-time": constants.QueueMaxSizeTime, - "max-size-bytes": constants.QueueMaxSizeBytes, - "max-size-buffers": constants.QueueMaxSizeBuffers, + "max-size-time": uint64(constants.QueueMaxSizeTime), + "max-size-bytes": uint32(constants.QueueMaxSizeBytes), + "max-size-buffers": uint32(constants.QueueMaxSizeBuffers), }) - if err != nil { - return nil, fmt.Errorf("failed to create multiqueue element: %w", err) + if mq == nil { + return nil, fmt.Errorf("failed to create multiqueue element") } - err = bin.Add(mq) - if err != nil { - return nil, fmt.Errorf("failed to add multiqueue to bin: %w", err) + if !bin.Add(mq) { + return nil, fmt.Errorf("failed to add multiqueue to bin") } - // err = mq.SetProperty("max-size-time", uint64(200000000000)) - // if err != nil { - // return nil, fmt.Errorf("failed to set max-size-time: %w", err) - // } - // err = mq.SetProperty("max-size-bytes", uint(1048576000)) - // if err != nil { - // return nil, fmt.Errorf("failed to set max-size-bytes: %w", err) - // } - // err = mq.SetProperty("max-size-buffers", uint(500)) - // if err != nil { - // return nil, fmt.Errorf("failed to set max-size-buffers: %w", err) - // } - opusparse, err := gst.NewElementWithProperties("opusparse", map[string]interface{}{ + opusparse := gst.ElementFactoryMakeWithProperties("opusparse", map[string]interface{}{ "name": "concat-demux-opusparse", "disable-passthrough": true, }) - if err != nil { - return nil, fmt.Errorf("failed to create opusparse element: %w", err) + if opusparse == nil { + return nil, fmt.Errorf("failed to create opusparse element") } - err = bin.Add(opusparse) - if err != nil { - return nil, fmt.Errorf("failed to add opusparse to bin: %w", err) + if !bin.Add(opusparse) { + return nil, fmt.Errorf("failed to add opusparse to bin") } opusparseSinkPad := opusparse.GetStaticPad("sink") if opusparseSinkPad == nil { @@ -105,12 +88,12 @@ func ConcatDemuxBin(ctx context.Context, seg *bus.Seg, doH264Parse bool) (*gst.B return nil, fmt.Errorf("failed to get opusparse source pad") } - mqVideoSink := mq.GetRequestPad("sink_%u") + mqVideoSink := mq.RequestPadSimple("sink_%u") if mqVideoSink == nil { return nil, fmt.Errorf("video sink pad not found") } - mqAudioSink := mq.GetRequestPad("sink_%u") + mqAudioSink := mq.RequestPadSimple("sink_%u") if mqAudioSink == nil { return nil, fmt.Errorf("audio sink pad not found") } @@ -130,19 +113,18 @@ func ConcatDemuxBin(ctx context.Context, seg *bus.Seg, doH264Parse bool) (*gst.B return nil, fmt.Errorf("failed to link opusparse sink pad to mq audio sink pad") } - var videoGhost *gst.GhostPad + var videoGhost gst.Pad if doH264Parse { - h264parse, err := gst.NewElementWithProperties("h264parse", map[string]interface{}{ + h264parse := gst.ElementFactoryMakeWithProperties("h264parse", map[string]interface{}{ "name": "concat-demux-h264parse", - "config-interval": -1, + "config-interval": int32(-1), "disable-passthrough": true, }) - if err != nil { - return nil, fmt.Errorf("failed to create h264parse element: %w", err) + if h264parse == nil { + return nil, fmt.Errorf("failed to create h264parse element") } - err = bin.Add(h264parse) - if err != nil { - return nil, fmt.Errorf("failed to add h264parse to bin: %w", err) + if !bin.Add(h264parse) { + return nil, fmt.Errorf("failed to add h264parse to bin") } h264parseSinkPad := h264parse.GetStaticPad("sink") if h264parseSinkPad == nil { @@ -186,8 +168,8 @@ func ConcatDemuxBin(ctx context.Context, seg *bus.Seg, doH264Parse bool) (*gst.B // here is a data race; a mutex held across dispatch risks blocking the // swap behind a stuck handler, pinning the closures). var padAdded atomic.Value - padAdded.Store(func(self *gst.Element, pad *gst.Pad) { - var downstreamPad *gst.Pad + padAdded.Store(func(self gst.Element, pad gst.Pad) { + var downstreamPad gst.Pad if strings.HasPrefix(pad.GetName(), "video_") { downstreamPad = mqVideoSink videoLinked = true @@ -210,8 +192,8 @@ func ConcatDemuxBin(ctx context.Context, seg *bus.Seg, doH264Parse bool) (*gst.B padAdded.Store(doNothing) } }) - outerPadAdded := func(self *gst.Element, pad *gst.Pad) { - padAdded.Load().(func(self *gst.Element, pad *gst.Pad))(self, pad) + outerPadAdded := func(self gst.Element, pad gst.Pad) { + padAdded.Load().(func(self gst.Element, pad gst.Pad))(self, pad) } // Both branches are wired up before the demux runs, but the segment may @@ -225,19 +207,19 @@ func ConcatDemuxBin(ctx context.Context, seg *bus.Seg, doH264Parse bool) (*gst.B // drain. Fires on the demux's streaming thread, same as pad-added, so the // linked flags need no synchronization. var noMorePads atomic.Value - noMorePads.Store(func(self *gst.Element) { + noMorePads.Store(func(self gst.Element) { if !videoLinked { log.Warn(ctx, "segment has no video track; completing video branch with EOS") - mqVideoSink.SendEvent(gst.NewEOSEvent()) + mqVideoSink.SendEvent(gst.NewEventEOS()) } if !audioLinked { log.Warn(ctx, "segment has no audio track; completing audio branch with EOS") - mqAudioSink.SendEvent(gst.NewEOSEvent()) + mqAudioSink.SendEvent(gst.NewEventEOS()) } noMorePads.Store(doNothingElement) }) - outerNoMorePads := func(self *gst.Element) { - noMorePads.Load().(func(self *gst.Element))(self) + outerNoMorePads := func(self gst.Element) { + noMorePads.Load().(func(self gst.Element))(self) } // Necessary to avoid leaking `mqVideoSink` and `mqAudioSink` from the @@ -248,28 +230,22 @@ func ConcatDemuxBin(ctx context.Context, seg *bus.Seg, doH264Parse bool) (*gst.B noMorePads.Store(doNothingElement) }() - _, err = demux.Connect("pad-added", outerPadAdded) - if err != nil { - return nil, fmt.Errorf("failed to connect demux pad-added signal: %w", err) - } - _, err = demux.Connect("no-more-pads", outerNoMorePads) - if err != nil { - return nil, fmt.Errorf("failed to connect demux no-more-pads signal: %w", err) - } + demux.ConnectPadAdded(outerPadAdded) + demux.ConnectNoMorePads(outerNoMorePads) - ok := bin.AddPad(videoGhost.Pad) + ok := bin.AddPad(videoGhost) if !ok { return nil, fmt.Errorf("failed to add video ghost pad to bin") } - ok = bin.AddPad(audioGhost.Pad) + ok = bin.AddPad(audioGhost) if !ok { return nil, fmt.Errorf("failed to add audio ghost pad to bin") } - src := app.SrcFromElement(appSrc) - src.SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: ReaderNeedDataIncremental(ctx, bytes.NewReader(seg.Data)), + src := appSrc.(app.AppSrc) + src.SetCallbacks(&app.AppSrcCallbacks{ + NeedData: ReaderNeedDataIncremental(ctx, bytes.NewReader(seg.Data)), }) return bin, nil diff --git a/pkg/media/concat_demux_test.go b/pkg/media/concat_demux_test.go index 339db61d0..d2d29f6cd 100644 --- a/pkg/media/concat_demux_test.go +++ b/pkg/media/concat_demux_test.go @@ -8,8 +8,8 @@ import ( "os" "testing" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" "stream.place/streamplace/pkg/bus" @@ -33,10 +33,7 @@ func innerTestConcatDemuxBin(t *testing.T) error { ctx := log.WithDebugValue(context.Background(), map[string]map[string]int{"func": {"ConcatStream": 9, "TestConcat2": 9, "SegDemuxBin": 9}}) ctx = log.WithLogValues(ctx, "func", "TestConcat2") - pipeline, err := gst.NewPipeline("TestConcat2") - if err != nil { - return fmt.Errorf("failed to create pipeline: %w", err) - } + pipeline := gst.NewPipeline("TestConcat2").(gst.Pipeline) ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -69,9 +66,8 @@ func innerTestConcatDemuxBin(t *testing.T) error { return fmt.Errorf("failed to create concat bin: %w", err) } - err = pipeline.Add(concatBin.Element) - if err != nil { - return fmt.Errorf("failed to add concat bin to pipeline: %w", err) + if !pipeline.Add(concatBin) { + return fmt.Errorf("failed to add concat bin to pipeline") } videoPad := concatBin.GetStaticPad("video_0") @@ -84,17 +80,16 @@ func innerTestConcatDemuxBin(t *testing.T) error { return fmt.Errorf("audio pad not found") } - videoAppSink, err := gst.NewElementWithProperties("appsink", map[string]interface{}{ + videoAppSink := gst.ElementFactoryMakeWithProperties("appsink", map[string]interface{}{ "name": "videoappsink", "sync": false, }) - if err != nil { - return fmt.Errorf("failed to create video appsink: %w", err) + if videoAppSink == nil { + return fmt.Errorf("failed to create video appsink") } - err = pipeline.Add(videoAppSink) - if err != nil { - return fmt.Errorf("failed to add video appsink to pipeline: %w", err) + if !pipeline.Add(videoAppSink) { + return fmt.Errorf("failed to add video appsink to pipeline") } videoAppSinkPadSink := videoAppSink.GetStaticPad("sink") @@ -102,17 +97,16 @@ func innerTestConcatDemuxBin(t *testing.T) error { return fmt.Errorf("video appsink pad not found") } - audioAppSink, err := gst.NewElementWithProperties("appsink", map[string]interface{}{ + audioAppSink := gst.ElementFactoryMakeWithProperties("appsink", map[string]interface{}{ "name": "audioappsink", "sync": false, }) - if err != nil { - return fmt.Errorf("failed to create audio appsink: %w", err) + if audioAppSink == nil { + return fmt.Errorf("failed to create audio appsink") } - err = pipeline.Add(audioAppSink) - if err != nil { - return fmt.Errorf("failed to add audio appsink to pipeline: %w", err) + if !pipeline.Add(audioAppSink) { + return fmt.Errorf("failed to add audio appsink to pipeline") } audioAppSinkPadSink := audioAppSink.GetStaticPad("sink") @@ -133,29 +127,27 @@ func innerTestConcatDemuxBin(t *testing.T) error { videoBuf := bytes.Buffer{} audioBuf := bytes.Buffer{} - videoappsink := app.SinkFromElement(videoAppSink) - videoappsink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, &videoBuf), + videoappsink := videoAppSink.(app.AppSink) + videoappsink.SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, &videoBuf), }) - audioappsink := app.SinkFromElement(audioAppSink) - audioappsink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, &audioBuf), + audioappsink := audioAppSink.(app.AppSink) + audioappsink.SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, &audioBuf), }) // Start the pipeline - err = pipeline.SetState(gst.StatePlaying) - if err != nil { - return fmt.Errorf("failed to set pipeline to playing state: %w", err) + if pipeline.SetState(gst.StatePlaying) == gst.StateChangeFailure { + return fmt.Errorf("failed to set pipeline to playing state") } defer func() { if err != nil { t.Errorf("bus handler error: %v", err) } - err = pipeline.BlockSetState(gst.StateNull) - if err != nil { - t.Errorf("failed to set pipeline to null state: %v", err) + if pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone) != gst.StateChangeSuccess { + t.Error("failed to set pipeline to null state") } require.Equal(t, 987291, videoBuf.Len()) require.Equal(t, 6440, audioBuf.Len()) diff --git a/pkg/media/gstreamer.go b/pkg/media/gstreamer.go index f3c1f4d11..a2ca37a26 100644 --- a/pkg/media/gstreamer.go +++ b/pkg/media/gstreamer.go @@ -11,9 +11,9 @@ import ( "strings" "time" - "github.com/go-gst/go-glib/glib" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-glib/pkg/glib/v2" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/skip2/go-qrcode" "golang.org/x/sync/errgroup" "stream.place/streamplace/pkg/aqtime" @@ -35,6 +35,38 @@ func SafePipe() (*os.File, *os.File, func(), error) { }, nil } +// newBufferFromBytes copies bs into a freshly allocated buffer. +func newBufferFromBytes(bs []byte) (*gst.Buffer, error) { + buffer := gst.NewBufferAllocate(nil, uint(len(bs)), nil) + info, ok := buffer.Map(gst.MapWrite) + if !ok { + return nil, fmt.Errorf("failed to map buffer for writing") + } + defer info.Unmap() + copy(info.Data(), bs) + return buffer, nil +} + +// copyBufferData returns a copy of the buffer's contents that stays valid +// after the buffer is released. +func copyBufferData(buffer *gst.Buffer) ([]byte, error) { + info, ok := buffer.Map(gst.MapRead) + if !ok { + return nil, fmt.Errorf("failed to map buffer for reading") + } + defer info.Unmap() + return bytes.Clone(info.Data()), nil +} + +// clockTimeDuration converts a gst.ClockTime to a time.Duration; ok is false +// when the time is unset (gst.ClockTimeNone). +func clockTimeDuration(t gst.ClockTime) (d time.Duration, ok bool) { + if t == gst.ClockTimeNone { + return 0, false + } + return time.Duration(t), true +} + // basic test to make sure gstreamer functionality is working func SelfTest(ctx context.Context) error { ctx = log.WithLogValues(ctx, "mediafunc", "SelfTest") @@ -50,83 +82,52 @@ func SelfTest(ctx context.Context) error { return fmt.Errorf("failed to read test file: %w", err) } - pipeline, err := gst.NewPipeline("self-test") - if err != nil { - return fmt.Errorf("failed to create pipeline: %w", err) - } + pipeline := gst.NewPipeline("self-test").(gst.Pipeline) - srcele, err := gst.NewElementWithProperties("appsrc", map[string]interface{}{ + srcele := gst.ElementFactoryMakeWithProperties("appsrc", map[string]any{ "name": "self-test-src", }) - if err != nil { - return fmt.Errorf("failed to create appsrc element: %w", err) + if srcele == nil { + return fmt.Errorf("failed to create appsrc element") } - err = pipeline.Add(srcele) - if err != nil { - return fmt.Errorf("failed to add appsrc to pipeline: %w", err) + if !pipeline.Add(srcele) { + return fmt.Errorf("failed to add appsrc to pipeline") } - sinkele, err := gst.NewElementWithProperties("appsink", map[string]interface{}{ + sinkele := gst.ElementFactoryMakeWithProperties("appsink", map[string]any{ "name": "self-test-sink", "sync": false, }) - if err != nil { - return fmt.Errorf("failed to create appsink element: %w", err) + if sinkele == nil { + return fmt.Errorf("failed to create appsink element") } - err = pipeline.Add(sinkele) - if err != nil { - return fmt.Errorf("failed to add appsink to pipeline: %w", err) + if !pipeline.Add(sinkele) { + return fmt.Errorf("failed to add appsink to pipeline") } - err = srcele.Link(sinkele) - if err != nil { - return fmt.Errorf("failed to link appsrc to appsink: %w", err) + if !srcele.Link(sinkele) { + return fmt.Errorf("failed to link appsrc to appsink") } - // pipeline, err := gst.NewPipelineFromString("appsrc name=src ! appsink name=sink") - // if err != nil { - // return err - // } - - src := app.SrcFromElement(srcele) - src.SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: func(self *app.Source, _ uint) { - buffer := gst.NewBufferWithSize(int64(len(bs))) - buffer.Map(gst.MapWrite).WriteData(bs) - defer buffer.Unmap() + srcele.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: func(self app.AppSrc, _ uint) { + buffer, err := newBufferFromBytes(bs) + if err != nil { + log.Error(ctx, "failed to build buffer", "error", err) + cancel() + return + } self.PushBuffer(buffer) log.Debug(ctx, "ending stream") - self.EndStream() + self.EndOfStream() }, }) output := &bytes.Buffer{} - if err != nil { - return fmt.Errorf("unexpected error: %w", err) - } - - appsink := app.SinkFromElement(sinkele) - appsink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { - sample := sink.PullSample() - if sample == nil { - return gst.FlowOK - } - // defer sample.Unref() - - // Retrieve the buffer from the sample. - buffer := sample.GetBuffer() - - _, err := io.Copy(output, buffer.Reader()) - - if err != nil { - panic(err) - } - - return gst.FlowOK - }, - EOSFunc: func(sink *app.Sink) { + sinkele.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, output), + EOS: func(sink app.AppSink) { log.Debug(ctx, "EOSFunc") cancel() }, @@ -141,9 +142,8 @@ func SelfTest(ctx context.Context) error { // Start the pipeline log.Debug(ctx, "setting pipeline to playing state") - err = pipeline.SetState(gst.StatePlaying) - if err != nil { - return fmt.Errorf("failed to set pipeline to playing state: %w", err) + if ret := pipeline.SetState(gst.StatePlaying); ret == gst.StateChangeFailure { + return fmt.Errorf("failed to set pipeline to playing state") } <-ctx.Done() @@ -152,9 +152,8 @@ func SelfTest(ctx context.Context) error { return fmt.Errorf("got a zero-byte buffer from SelfTest") } - err = pipeline.BlockSetState(gst.StateNull) - if err != nil { - return fmt.Errorf("failed to set pipeline to null state: %w", err) + if ret := pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone); ret == gst.StateChangeFailure { + return fmt.Errorf("failed to set pipeline to null state") } return nil @@ -181,46 +180,47 @@ func (mm *MediaManager) TestSource(ctx context.Context, ms MediaSigner) error { "audiotestsrc ! audioconvert ! opusenc inband-fec=true perfect-timestamp=true bitrate=128000 ! queue ! opusparse name=audioparse", } - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) + pipelineElem, err := gst.ParseLaunch(strings.Join(pipelineSlice, "\n")) if err != nil { return fmt.Errorf("error creating TestSource pipeline: %w", err) } + pipeline, ok := pipelineElem.(gst.Pipeline) + if !ok { + return fmt.Errorf("error creating TestSource pipeline: not a pipeline") + } - videoparse, err := pipeline.GetElementByName("videoparse") - if err != nil { - return err + videoparse := pipeline.GetByName("videoparse") + if videoparse == nil { + return fmt.Errorf("videoparse element not found") } - audioparse, err := pipeline.GetElementByName("audioparse") - if err != nil { - return err + audioparse := pipeline.GetByName("audioparse") + if audioparse == nil { + return fmt.Errorf("audioparse element not found") } signer, err := mm.SegmentAndSignElem(ctx, ms) if err != nil { return err } - if err := pipeline.Add(signer); err != nil { - return err + if !pipeline.Add(signer) { + return fmt.Errorf("failed to add signer to pipeline") } - err = videoparse.Link(signer) - if err != nil { - return fmt.Errorf("link to signer failed: %w", err) + if !videoparse.Link(signer) { + return fmt.Errorf("link to signer failed") } - err = audioparse.Link(signer) - if err != nil { - return fmt.Errorf("link to signer failed: %w", err) + if !audioparse.Link(signer) { + return fmt.Errorf("link to signer failed") } - pngele, err := pipeline.GetElementByName("pngsrc") - if err != nil { - return err + pngele := pipeline.GetByName("pngsrc") + if pngele == nil { + return fmt.Errorf("pngsrc element not found") } - src := app.SrcFromElement(pngele) - src.SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: func(self *app.Source, _ uint) { + pngele.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: func(self app.AppSrc, _ uint) { now := time.Now().UnixMilli() data := QRData{Now: now} bs, err := json.Marshal(data) @@ -231,9 +231,10 @@ func (mm *MediaManager) TestSource(ctx context.Context, ms MediaSigner) error { if err != nil { panic(err) } - buffer := gst.NewBufferWithSize(int64(len(png))) - buffer.Map(gst.MapWrite).WriteData(png) - defer buffer.Unmap() + buffer, err := newBufferFromBytes(png) + if err != nil { + panic(err) + } self.PushBuffer(buffer) }, }) @@ -241,22 +242,22 @@ func (mm *MediaManager) TestSource(ctx context.Context, ms MediaSigner) error { if err != nil { return err } - timetext, err := pipeline.GetElementByName("timetext") - if err != nil { - return err + timetext := pipeline.GetByName("timetext") + if timetext == nil { + return fmt.Errorf("timetext element not found") } - timesrc := app.SrcFromElement(timetext) - timesrc.SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: func(self *app.Source, _ uint) { + timetext.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: func(self app.AppSrc, _ uint) { aqt := aqtime.FromTime(time.Now()) png, err := tr.GenerateImage(aqt.String(), "#ffffff", "#000000", 36) if err != nil { panic(err) } - buffer := gst.NewBufferWithSize(int64(len(png))) - buffer.Map(gst.MapWrite).WriteData(png) - defer buffer.Unmap() + buffer, err := newBufferFromBytes(png) + if err != nil { + panic(err) + } self.PushBuffer(buffer) }, }) @@ -264,8 +265,8 @@ func (mm *MediaManager) TestSource(ctx context.Context, ms MediaSigner) error { defer cancel() go func() { <-ctx.Done() - if err := pipeline.BlockSetState(gst.StateNull); err != nil { - log.Log(ctx, "failed to set pipeline state", "error", err) + if ret := pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone); ret == gst.StateChangeFailure { + log.Log(ctx, "failed to set pipeline state", "error", ret.String()) } mainLoop.Quit() }() @@ -278,8 +279,8 @@ func (mm *MediaManager) TestSource(ctx context.Context, ms MediaSigner) error { }() // Start the pipeline - if err := pipeline.SetState(gst.StatePlaying); err != nil { - log.Log(ctx, "failed to set pipeline state", "error", err) + if ret := pipeline.SetState(gst.StatePlaying); ret == gst.StateChangeFailure { + log.Log(ctx, "failed to set pipeline state", "error", ret.String()) } g, _ := errgroup.WithContext(ctx) diff --git a/pkg/media/ingest_worker.go b/pkg/media/ingest_worker.go index f2f54d4b5..d303ebcac 100644 --- a/pkg/media/ingest_worker.go +++ b/pkg/media/ingest_worker.go @@ -8,7 +8,7 @@ import ( "net/http/httputil" "sync" - "github.com/go-gst/go-gst/gst" + "github.com/go-gst/go-gst/pkg/gst" "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/gstinit" "stream.place/streamplace/pkg/log" @@ -260,12 +260,12 @@ func RunMP4IngestWorker(ctx context.Context, cfg IngestWorkerConfig, stdin io.Re busErr <- HandleBusMessages(ctx, pipeline) }() - if err := pipeline.SetState(gst.StatePlaying); err != nil { - return fmt.Errorf("set playing: %w", err) + if pipeline.SetState(gst.StatePlaying) == gst.StateChangeFailure { + return fmt.Errorf("set playing: state change failed") } defer func() { - if err := pipeline.SetState(gst.StateNull); err != nil { - log.Error(ctx, "ingest worker: set null", "error", err) + if pipeline.SetState(gst.StateNull) == gst.StateChangeFailure { + log.Error(ctx, "ingest worker: set null failed") } }() diff --git a/pkg/media/ingest_worker_test.go b/pkg/media/ingest_worker_test.go index d5a298e22..97aff6108 100644 --- a/pkg/media/ingest_worker_test.go +++ b/pkg/media/ingest_worker_test.go @@ -15,8 +15,8 @@ import ( "testing" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/stretchr/testify/require" "stream.place/streamplace/pkg/crypto/signers" "stream.place/streamplace/pkg/gstinit" @@ -56,20 +56,21 @@ func makeH264AACFMP4(t *testing.T, ctx context.Context, srcMP4 string) []byte { "d. ! queue ! h264parse ! mp4mux name=mux fragment-duration=500 ! appsink name=sink", "d. ! queue ! opusdec ! audioconvert ! audioresample ! fdkaacenc ! aacparse ! mux.", }, "\n") - pipeline, err := gst.NewPipelineFromString(desc) + launched, err := gst.ParseLaunch(desc) require.NoError(t, err) + pipeline := launched.(gst.Pipeline) - sinkEle, err := pipeline.GetElementByName("sink") - require.NoError(t, err) + sinkEle := pipeline.GetByName("sink") + require.NotNil(t, sinkEle) var buf bytes.Buffer - app.SinkFromElement(sinkEle).SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, &buf), + sinkEle.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, &buf), }) busErr := make(chan error, 1) go func() { busErr <- HandleBusMessages(ctx, pipeline) }() - require.NoError(t, pipeline.SetState(gst.StatePlaying)) - defer func() { _ = pipeline.SetState(gst.StateNull) }() + require.NotEqual(t, gst.StateChangeFailure, pipeline.SetState(gst.StatePlaying)) + defer func() { pipeline.SetState(gst.StateNull) }() require.NoError(t, <-busErr, "remux to fragmented H264+AAC MP4") require.NotEmpty(t, buf.Bytes(), "remux produced an fMP4") return buf.Bytes() @@ -85,20 +86,21 @@ func makeAudioOnlyAACFMP4(t *testing.T, ctx context.Context, seconds int) []byte t.Helper() gstinit.InitGST() desc := fmt.Sprintf("audiotestsrc num-buffers=%d samplesperbuffer=1024 ! audio/x-raw,rate=48000,channels=2 ! audioconvert ! fdkaacenc ! aacparse ! mp4mux fragment-duration=500 ! appsink name=sink", seconds*47) - pipeline, err := gst.NewPipelineFromString(desc) + launched, err := gst.ParseLaunch(desc) require.NoError(t, err) + pipeline := launched.(gst.Pipeline) - sinkEle, err := pipeline.GetElementByName("sink") - require.NoError(t, err) + sinkEle := pipeline.GetByName("sink") + require.NotNil(t, sinkEle) var buf bytes.Buffer - app.SinkFromElement(sinkEle).SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, &buf), + sinkEle.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, &buf), }) busErr := make(chan error, 1) go func() { busErr <- HandleBusMessages(ctx, pipeline) }() - require.NoError(t, pipeline.SetState(gst.StatePlaying)) - defer func() { _ = pipeline.SetState(gst.StateNull) }() + require.NotEqual(t, gst.StateChangeFailure, pipeline.SetState(gst.StatePlaying)) + defer func() { pipeline.SetState(gst.StateNull) }() require.NoError(t, <-busErr, "synthesize audio-only fMP4") require.NotEmpty(t, buf.Bytes()) return buf.Bytes() diff --git a/pkg/media/io_helpers.go b/pkg/media/io_helpers.go index 357827c06..1fdf6a0ca 100644 --- a/pkg/media/io_helpers.go +++ b/pkg/media/io_helpers.go @@ -4,44 +4,45 @@ import ( "context" "errors" "io" - "runtime" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "stream.place/streamplace/pkg/log" ) // ReaderNeedData is a function that reads from an io.Reader and pushes the data to a gstreamer source. -func ReaderNeedData(ctx context.Context, input io.Reader) func(self *app.Source, length uint) { +func ReaderNeedData(ctx context.Context, input io.Reader) func(self app.AppSrc, length uint) { bsCopy, err := io.ReadAll(input) if err != nil { log.Error(ctx, "error reading from input", "error", err) } wrote := false - return func(self *app.Source, length uint) { + return func(self app.AppSrc, length uint) { if ctx.Err() != nil { - self.EndStream() + self.EndOfStream() return } if wrote { log.Error(ctx, "ReaderNeedData: called after already wrote") - ret := self.EndStream() + ret := self.EndOfStream() if ret != gst.FlowOK { log.Error(ctx, "failed to end stream after already wrote", "error", ret.String()) } return } wrote = true - buffer := gst.NewBufferWithSize(int64(len(bsCopy))) - buffer.Map(gst.MapWrite).WriteData(bsCopy) - defer buffer.Unmap() + buffer, err := newBufferFromBytes(bsCopy) + if err != nil { + self.PostMessage(gst.NewMessageError(self, "error copying input", err)) + return + } ret := self.PushBuffer(buffer) if ret != gst.FlowOK { log.Error(ctx, "failed to push buffer", "error", ret.String()) } else { log.Debug(ctx, "pushed buffer", "length", len(bsCopy)) } - ret = self.EndStream() + ret = self.EndOfStream() if ret != gst.FlowOK { log.Error(ctx, "failed to end stream", "error", ret.String()) } @@ -49,17 +50,17 @@ func ReaderNeedData(ctx context.Context, input io.Reader) func(self *app.Source, } // Different from ReaderNeedData in that it reads the data in chunks and pushes them to the source. -func ReaderNeedDataIncremental(ctx context.Context, input io.Reader) func(self *app.Source, length uint) { - return func(self *app.Source, length uint) { +func ReaderNeedDataIncremental(ctx context.Context, input io.Reader) func(self app.AppSrc, length uint) { + return func(self app.AppSrc, length uint) { if ctx.Err() != nil { - self.EndStream() + self.EndOfStream() return } bs := make([]byte, length) read, err := input.Read(bs) if err != nil && !errors.Is(err, io.EOF) { log.Error(ctx, "error reading from input", "error", err) - self.Error("error reading from input", err) + self.PostMessage(gst.NewMessageError(self, "error reading from input", err)) return } if read > 0 { @@ -67,38 +68,39 @@ func ReaderNeedDataIncremental(ctx context.Context, input io.Reader) func(self * if uint(read) < length { toPush = bs[:read] } - buffer := gst.NewBufferWithSize(int64(len(toPush))) - buffer.Map(gst.MapWrite).WriteData(toPush) - defer buffer.Unmap() + buffer, err := newBufferFromBytes(toPush) + if err != nil { + self.PostMessage(gst.NewMessageError(self, "error copying input", err)) + return + } self.PushBuffer(buffer) } if err != nil && errors.Is(err, io.EOF) { log.Debug(ctx, "EOF, ending stream", "length", read) - self.EndStream() + self.EndOfStream() return } } } // WriterNewSample is a function that reads from a gstreamer sink and writes the data to an io.Writer. -func WriterNewSample(ctx context.Context, output io.Writer) func(sink *app.Sink) gst.FlowReturn { - return func(sink *app.Sink) gst.FlowReturn { +func WriterNewSample(ctx context.Context, output io.Writer) func(sink app.AppSink) gst.FlowReturn { + return func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { return gst.FlowOK } - // GstSample is an opaque C type, so this small wrapper can share a - // Go tiny-allocation block with a live object and never be finalized. - // Release our owned reference when the callback finishes instead. - runtime.SetFinalizer(sample, nil) - defer sample.Unref() // Retrieve the buffer from the sample. buffer := sample.GetBuffer() - bs := buffer.Map(gst.MapRead).Bytes() - defer buffer.Unmap() + mapped, ok := buffer.Map(gst.MapRead) + if !ok { + log.Error(ctx, "failed to map output buffer") + return gst.FlowError + } + defer mapped.Unmap() - _, err := output.Write(bs) + _, err := output.Write(mapped.Data()) if err != nil { log.Error(ctx, "error writing to output", "error", err) diff --git a/pkg/media/io_helpers_test.go b/pkg/media/io_helpers_test.go index 4b5aac004..d024e877d 100644 --- a/pkg/media/io_helpers_test.go +++ b/pkg/media/io_helpers_test.go @@ -9,8 +9,8 @@ import ( "runtime" "testing" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" "stream.place/streamplace/pkg/log" @@ -70,20 +70,16 @@ func writerNewSampleInner(ctx context.Context, i int, w io.Writer) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - pipeline, err := gst.NewPipeline(fmt.Sprintf("TestWriterNewSample-%d", i)) - if err != nil { - return err - } + pipeline := gst.NewPipeline(fmt.Sprintf("TestWriterNewSample-%d", i)).(gst.Pipeline) - fileSrc, err := gst.NewElementWithProperties("filesrc", map[string]any{ + fileSrc := gst.ElementFactoryMakeWithProperties("filesrc", map[string]any{ "location": getFixture("5sec.mp4"), }) - if err != nil { - return err + if fileSrc == nil { + return fmt.Errorf("failed to create filesrc") } - err = pipeline.Add(fileSrc) - if err != nil { - return err + if !pipeline.Add(fileSrc) { + return fmt.Errorf("failed to add filesrc") } var busErr error @@ -92,38 +88,34 @@ func writerNewSampleInner(ctx context.Context, i int, w io.Writer) error { cancel() }() - appSink, err := gst.NewElementWithProperties("appsink", map[string]any{ + appSink := gst.ElementFactoryMakeWithProperties("appsink", map[string]any{ "name": fmt.Sprintf("TestWriterNewSample-appsink-%d", i), "sync": false, }) - if err != nil { - return err + if appSink == nil { + return fmt.Errorf("failed to create appsink") } - err = pipeline.Add(appSink) - if err != nil { - return err + if !pipeline.Add(appSink) { + return fmt.Errorf("failed to add appsink") } - sink := app.SinkFromElement(appSink) - sink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, w), + sink := appSink.(app.AppSink) + sink.SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, w), }) - err = fileSrc.Link(appSink) - if err != nil { - return err + if !fileSrc.Link(appSink) { + return fmt.Errorf("failed to link filesrc to appsink") } - err = pipeline.SetState(gst.StatePlaying) - if err != nil { - return err + if pipeline.SetState(gst.StatePlaying) == gst.StateChangeFailure { + return fmt.Errorf("failed to start writer pipeline") } <-ctx.Done() - err = pipeline.SetState(gst.StateNull) - if err != nil { - return err + if pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone) != gst.StateChangeSuccess { + return fmt.Errorf("failed to stop writer pipeline") } return busErr diff --git a/pkg/media/key_revocation.go b/pkg/media/key_revocation.go index b0b6fecce..6dcfbbd3b 100644 --- a/pkg/media/key_revocation.go +++ b/pkg/media/key_revocation.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/go-gst/go-gst/gst" + "github.com/go-gst/go-gst/pkg/gst" "stream.place/streamplace/pkg/atproto" "stream.place/streamplace/pkg/comatproto" "stream.place/streamplace/pkg/model" @@ -72,9 +72,9 @@ func (mm *MediaManager) watchKeyRevocation(ctx context.Context, streamer, did st // HandleKeyRevocation shuts down an in-process ingest pipeline when the // streamer's signing key is revoked or they get banned. -func (mm *MediaManager) HandleKeyRevocation(ctx context.Context, ms MediaSigner, pipeline *gst.Pipeline) { +func (mm *MediaManager) HandleKeyRevocation(ctx context.Context, ms MediaSigner, pipeline gst.Pipeline) { mm.watchKeyRevocation(ctx, ms.Streamer(), ms.DID(), func(reason string) { err := fmt.Errorf("ending stream: %s", reason) - pipeline.Error(err.Error(), err) + pipeline.PostMessage(gst.NewMessageError(pipeline, err.Error(), err)) }) } diff --git a/pkg/media/keyframe_probe.go b/pkg/media/keyframe_probe.go index 21d0fb505..f7844d642 100644 --- a/pkg/media/keyframe_probe.go +++ b/pkg/media/keyframe_probe.go @@ -4,7 +4,7 @@ import ( "context" "encoding/binary" - "github.com/go-gst/go-gst/gst" + "github.com/go-gst/go-gst/pkg/gst" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/spmetrics" ) @@ -62,21 +62,21 @@ func auHasIDR(b []byte) bool { // packetizer finds no frame in it, and an HLS player joining there stalls. // Clearing the flag keeps such frames inside the GoP of the IDR before // them. -func installIDRKeyframeProbe(ctx context.Context, pad *gst.Pad, streamer string) { +func installIDRKeyframeProbe(ctx context.Context, pad gst.Pad, streamer string) { if pad == nil { return } - pad.AddProbe(gst.PadProbeTypeBuffer, func(_ *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn { + pad.AddProbe(gst.PadProbeTypeBuffer, func(_ gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn { buf := info.GetBuffer() if buf == nil || buf.HasFlags(gst.BufferFlagDeltaUnit) { return gst.PadProbeOK } - m := buf.Map(gst.MapRead) - if m == nil { + m, ok := buf.Map(gst.MapRead) + if !ok { return gst.PadProbeOK } - idr := auHasIDR(m.Bytes()) - buf.Unmap() + idr := auHasIDR(m.Data()) + m.Unmap() if !idr { buf.SetFlags(gst.BufferFlagDeltaUnit) spmetrics.IngestNonIDRKeyframesTotal.WithLabelValues(streamer).Inc() diff --git a/pkg/media/leak/leak.go b/pkg/media/leak/leak.go index 81c51faee..87a055995 100644 --- a/pkg/media/leak/leak.go +++ b/pkg/media/leak/leak.go @@ -9,8 +9,8 @@ import ( "syscall" "time" - "github.com/go-gst/go-glib/glib" - "github.com/go-gst/go-gst/gst" + "github.com/go-gst/go-glib/pkg/glib/v2" + "github.com/go-gst/go-gst/pkg/gst" ) // Demonstration of a GStreamer (or go-gst idk) leak @@ -40,18 +40,19 @@ func main() { } func RunPipeline(file string) error { - gst.Init(nil) - pipeline, err := gst.NewPipelineFromString(fmt.Sprintf("filesrc location=%s ! qtdemux ! fakesink", file)) + gst.Init() + element, err := gst.ParseLaunch(fmt.Sprintf("filesrc location=%s ! qtdemux ! fakesink", file)) if err != nil { return fmt.Errorf("failed to create pipeline: %w", err) } + pipeline := element.(gst.Pipeline) mainLoop := glib.NewMainLoop(glib.MainContextDefault(), false) - if err := pipeline.BlockSetState(gst.StatePlaying); err != nil { - return fmt.Errorf("failed to set pipeline state: %w", err) + if status := pipeline.BlockSetState(gst.StatePlaying, gst.ClockTimeNone); status != gst.StateChangeSuccess { + return fmt.Errorf("failed to set pipeline state: %s", status) } - pipeline.GetBus().AddWatch(func(msg *gst.Message) bool { + pipeline.GetBus().AddWatchFull(0, func(_ gst.Bus, msg *gst.Message) bool { if msg.Type() == gst.MessageEOS { mainLoop.Quit() return false @@ -61,8 +62,8 @@ func RunPipeline(file string) error { mainLoop.Run() - if err := pipeline.BlockSetState(gst.StateNull); err != nil { - return fmt.Errorf("failed to set pipeline state: %w", err) + if status := pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone); status != gst.StateChangeSuccess { + return fmt.Errorf("failed to set pipeline state: %s", status) } return nil diff --git a/pkg/media/media_data_parser.go b/pkg/media/media_data_parser.go index 7f1fa1203..eaa50067b 100644 --- a/pkg/media/media_data_parser.go +++ b/pkg/media/media_data_parser.go @@ -7,19 +7,18 @@ import ( "encoding/json" "fmt" "io" - "strconv" "strings" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "go.opentelemetry.io/otel" "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/localdb" "stream.place/streamplace/pkg/log" ) -func padProbeEmpty(_ *gst.Pad, _ *gst.PadProbeInfo) gst.PadProbeReturn { +func padProbeEmpty(_ gst.Pad, _ *gst.PadProbeInfo) gst.PadProbeReturn { return gst.PadProbeOK } @@ -50,71 +49,65 @@ func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*localdb.SegmentM fmt.Sprintf("demux.audio_0 ! %s ! appsink sync=false name=audioappsink", constants.Queue2Big), } - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) + launched, err := gst.ParseLaunch(strings.Join(pipelineSlice, "\n")) if err != nil { return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) } + pipeline, ok := launched.(gst.Pipeline) + if !ok { + return nil, fmt.Errorf("error creating SegmentMetadata pipeline: parsed description is not a pipeline") + } var videoMetadata *localdb.SegmentMediadataVideo var audioMetadata *localdb.SegmentMediadataAudio var videoDuration time.Duration var audioDuration time.Duration - appsrc, err := pipeline.GetElementByName("appsrc") - if err != nil { - return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) + appsrc := pipeline.GetByName("appsrc") + if appsrc == nil { + return nil, fmt.Errorf("error creating SegmentMetadata pipeline: appsrc not found") } - - src := app.SrcFromElement(appsrc) - src.SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: ReaderNeedDataIncremental(ctx, bytes.NewReader(mp4bs)), + appsrc.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: ReaderNeedDataIncremental(ctx, bytes.NewReader(mp4bs)), }) foundSomeAudio := false - audioSinkElem, err := pipeline.GetElementByName("audioappsink") - if err != nil { - return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) - } - audioSink := app.SinkFromElement(audioSinkElem) + audioSink := pipeline.GetByName("audioappsink") if audioSink == nil { - return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) + return nil, fmt.Errorf("error creating SegmentMetadata pipeline: audioappsink not found") } - audioSink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: ParseSegmentMediaDataSinkNewSampleFunc(ctx, &foundSomeAudio, &audioDuration), + audioSink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: ParseSegmentMediaDataSinkNewSampleFunc(ctx, &foundSomeAudio, &audioDuration), }) foundSomeVideo := false - videoSinkElem, err := pipeline.GetElementByName("videoappsink") - if err != nil { - return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) - } - videoSink := app.SinkFromElement(videoSinkElem) + videoSink := pipeline.GetByName("videoappsink") if videoSink == nil { - return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) + return nil, fmt.Errorf("error creating SegmentMetadata pipeline: videoappsink not found") } - videoSink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: ParseSegmentMediaDataSinkNewSampleFunc(ctx, &foundSomeVideo, &videoDuration), + videoSink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: ParseSegmentMediaDataSinkNewSampleFunc(ctx, &foundSomeVideo, &videoDuration), }) padsAdded := 0 - var padProbe func(pad *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn - padProbe = func(pad *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn { - if info.GetEvent().Type() != gst.EventTypeEOS { + var padProbe func(pad gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn + padProbe = func(pad gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn { + if info.GetEvent().GetType() != gst.EventEOS { return gst.PadProbeOK } if padsAdded < 2 { err := fmt.Errorf("expected at least 2 tracks (video + audio), got %d", padsAdded) - pipeline.Error(err.Error(), err) + pipeline.PostMessage(gst.NewMessageError(pipeline, err.Error(), err)) } padProbe = padProbeEmpty return gst.PadProbeRemove } - outerPadProbe := func(pad *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn { + outerPadProbe := func(pad gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn { return padProbe(pad, info) } - onPadAdded := func(element *gst.Element, pad *gst.Pad) { + onPadAdded := func(_ gst.Element, pad gst.Pad) { padsAdded += 1 caps := pad.GetCurrentCaps() if caps == nil { @@ -125,41 +118,27 @@ func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*localdb.SegmentM pad.AddProbe(gst.PadProbeTypeEventBoth, outerPadProbe) - structure := caps.GetStructureAt(0) + structure := caps.GetStructure(0) if structure == nil { log.Warn(ctx, "Unable to get structure from caps") cancel() return } - name := structure.Name() + name := structure.GetName() if name[:5] == "video" { videoMetadata = &localdb.SegmentMediadataVideo{} // Get some common video properties - widthVal, _ := structure.GetValue("width") - heightVal, _ := structure.GetValue("height") - - width, ok := widthVal.(int) - if ok { - videoMetadata.Width = width + if width, ok := structure.GetInt("width"); ok { + videoMetadata.Width = int(width) } - height, ok := heightVal.(int) - if ok { - videoMetadata.Height = height + if height, ok := structure.GetInt("height"); ok { + videoMetadata.Height = int(height) } - framerateVal, _ := structure.GetValue("framerate") - framerateStr := fmt.Sprintf("%v", framerateVal) - parts := strings.Split(framerateStr, "/") - num := 0 - den := 0 - if len(parts) == 2 { - num, _ = strconv.Atoi(parts[0]) - den, _ = strconv.Atoi(parts[1]) - } - if num != 0 && den != 0 { - videoMetadata.FPSNum = num - videoMetadata.FPSDen = den + if num, den, ok := structure.GetFraction("framerate"); ok && num != 0 && den != 0 { + videoMetadata.FPSNum = int(num) + videoMetadata.FPSDen = int(den) } } @@ -168,16 +147,11 @@ func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*localdb.SegmentM // (audio_1+, on a dual-codec completed segment) are left unlinked. if name[:5] == "audio" && pad.GetName() == "audio_0" { audioMetadata = &localdb.SegmentMediadataAudio{} - rateVal, _ := structure.GetValue("rate") - channelsVal, _ := structure.GetValue("channels") - - rate, ok := rateVal.(int) - if ok { - audioMetadata.Rate = rate + if rate, ok := structure.GetInt("rate"); ok { + audioMetadata.Rate = int(rate) } - channels, ok := channelsVal.(int) - if ok { - audioMetadata.Channels = channels + if channels, ok := structure.GetInt("channels"); ok { + audioMetadata.Channels = int(channels) } } @@ -186,23 +160,17 @@ func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*localdb.SegmentM // } } - demux, err := pipeline.GetElementByName("demux") - if err != nil { - return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) - } - _, err = demux.Connect("pad-added", onPadAdded) - if err != nil { - return nil, fmt.Errorf("error connecting pad-add: %w", err) + demux := pipeline.GetByName("demux") + if demux == nil { + return nil, fmt.Errorf("error creating SegmentMetadata pipeline: demux not found") } + demux.ConnectPadAdded(onPadAdded) - jsonSinkElem, err := pipeline.GetElementByName("jsonappsink") - if err != nil { - return nil, fmt.Errorf("failed to get videoappsink element: %w", err) - } - jsonSink := app.SinkFromElement(jsonSinkElem) - if jsonSink == nil { - return nil, fmt.Errorf("failed to get videoappsink element: %w", err) + jsonSinkElem := pipeline.GetByName("jsonappsink") + if jsonSinkElem == nil { + return nil, fmt.Errorf("failed to get jsonappsink element") } + jsonSink := jsonSinkElem.(app.AppSink) hasBFrames := false @@ -237,15 +205,22 @@ func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*localdb.SegmentM close(decodeErr) }() - jsonSink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { + jsonSink.SetCallbacks(&app.AppSinkCallbacks{ + NewSample: func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { return gst.FlowOK } - buf := sample.GetBuffer().Bytes() - _, err := bufW.Write(buf) + info, ok := sample.GetBuffer().Map(gst.MapRead) + if !ok { + log.Error(ctx, "failed to map buffer") + return gst.FlowError + } + defer info.Unmap() + // bufio copies the mapped bytes into its own buffer (or writes them + // through to the pipe synchronously), so nothing escapes the mapping. + _, err := bufW.Write(info.Data()) if err != nil { log.Error(ctx, "failed to write to buffer", "error", err) return gst.FlowError @@ -263,13 +238,13 @@ func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*localdb.SegmentM }() // Start the pipeline - if err := pipeline.SetState(gst.StatePlaying); err != nil { - return nil, err + if pipeline.SetState(gst.StatePlaying) == gst.StateChangeFailure { + return nil, fmt.Errorf("error setting SegmentMetadata pipeline to playing") } defer func() { - if err := pipeline.BlockSetState(gst.StateNull); err != nil { - log.Error(ctx, "error setting pipeline state to null", "error", err) + if pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone) == gst.StateChangeFailure { + log.Error(ctx, "error setting pipeline state to null") } }() @@ -304,9 +279,10 @@ func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*localdb.SegmentM return meta, nil } -func ParseSegmentMediaDataSinkNewSampleFunc(ctx context.Context, foundThisTrack *bool, duration *time.Duration) func(sink *app.Sink) gst.FlowReturn { - var firstPTS *time.Time - return func(sink *app.Sink) gst.FlowReturn { +func ParseSegmentMediaDataSinkNewSampleFunc(ctx context.Context, foundThisTrack *bool, duration *time.Duration) func(sink app.AppSink) gst.FlowReturn { + var firstPTS gst.ClockTime + haveFirstPTS := false + return func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { return gst.FlowOK @@ -315,14 +291,14 @@ func ParseSegmentMediaDataSinkNewSampleFunc(ctx context.Context, foundThisTrack if buf == nil { return gst.FlowError } - pts := buf.PresentationTimestamp().AsTimestamp() - if firstPTS == nil { - firstPTS = pts + if pts := buf.PTS(); pts != gst.ClockTimeNone { + if !haveFirstPTS { + firstPTS = pts + haveFirstPTS = true + } + *duration = time.Duration(int64(pts) - int64(firstPTS)) } - diff := pts.Sub(*firstPTS) - *duration = diff - dur := buf.Duration().AsDuration() - if dur != nil && *dur > 0 { + if dur, ok := clockTimeDuration(buf.Duration()); ok && dur > 0 { *foundThisTrack = true } else { log.Warn(ctx, "no duration found for track", "track", sink.GetName()) diff --git a/pkg/media/mist_mp4_ingest_test.go b/pkg/media/mist_mp4_ingest_test.go index d933ca18b..b21049376 100644 --- a/pkg/media/mist_mp4_ingest_test.go +++ b/pkg/media/mist_mp4_ingest_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/stretchr/testify/require" "stream.place/streamplace/pkg/crypto/signers" "stream.place/streamplace/pkg/gstinit" @@ -87,20 +87,21 @@ func runMP4ThroughIngestWorkerSegments(t *testing.T, mp4 []byte, transcode bool) func runSynthPipeline(t *testing.T, ctx context.Context, desc string) []byte { t.Helper() gstinit.InitGST() - pipeline, err := gst.NewPipelineFromString(desc) + launched, err := gst.ParseLaunch(desc) require.NoError(t, err) + pipeline := launched.(gst.Pipeline) - sinkEle, err := pipeline.GetElementByName("sink") - require.NoError(t, err) + sinkEle := pipeline.GetByName("sink") + require.NotNil(t, sinkEle) var buf bytes.Buffer - app.SinkFromElement(sinkEle).SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, &buf), + sinkEle.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, &buf), }) busErr := make(chan error, 1) go func() { busErr <- HandleBusMessages(ctx, pipeline) }() - require.NoError(t, pipeline.SetState(gst.StatePlaying)) - defer func() { _ = pipeline.SetState(gst.StateNull) }() + require.NotEqual(t, gst.StateChangeFailure, pipeline.SetState(gst.StatePlaying)) + defer func() { pipeline.SetState(gst.StateNull) }() require.NoError(t, <-busErr, "synthesize test stream") require.NotEmpty(t, buf.Bytes()) return buf.Bytes() @@ -162,26 +163,27 @@ func videoPTSDTSOffsets(t *testing.T, ctx context.Context, segment []byte) []tim "demux.video_0 ! queue ! h264parse ! appsink name=sink sync=false", "demux.audio_0 ! queue ! fakesink sync=false", }, "\n") - pipeline, err := gst.NewPipelineFromString(desc) + launched, err := gst.ParseLaunch(desc) require.NoError(t, err) + pipeline := launched.(gst.Pipeline) - srcEle, err := pipeline.GetElementByName("src") - require.NoError(t, err) - app.SrcFromElement(srcEle).SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: ReaderNeedDataIncremental(ctx, bytes.NewReader(flat.Bytes())), + srcEle := pipeline.GetByName("src") + require.NotNil(t, srcEle) + srcEle.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: ReaderNeedDataIncremental(ctx, bytes.NewReader(flat.Bytes())), }) - sinkEle, err := pipeline.GetElementByName("sink") - require.NoError(t, err) + sinkEle := pipeline.GetByName("sink") + require.NotNil(t, sinkEle) var offsets []time.Duration - app.SinkFromElement(sinkEle).SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { + sinkEle.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { return gst.FlowEOS } buf := sample.GetBuffer() - pts, dts := buf.PresentationTimestamp(), buf.DecodingTimestamp() + pts, dts := buf.PTS(), buf.DTS() if pts != gst.ClockTimeNone && dts != gst.ClockTimeNone { offsets = append(offsets, time.Duration(int64(pts)-int64(dts))) } @@ -191,8 +193,8 @@ func videoPTSDTSOffsets(t *testing.T, ctx context.Context, segment []byte) []tim busErr := make(chan error, 1) go func() { busErr <- HandleBusMessages(ctx, pipeline) }() - require.NoError(t, pipeline.SetState(gst.StatePlaying)) - defer func() { _ = pipeline.SetState(gst.StateNull) }() + require.NotEqual(t, gst.StateChangeFailure, pipeline.SetState(gst.StatePlaying)) + defer func() { pipeline.SetState(gst.StateNull) }() require.NoError(t, <-busErr, "demux flat-wrapped segment") require.NotEmpty(t, offsets, "segment has video samples with timestamps") return offsets diff --git a/pkg/media/mp4_ingest.go b/pkg/media/mp4_ingest.go index d0dbf407e..59ed6100c 100644 --- a/pkg/media/mp4_ingest.go +++ b/pkg/media/mp4_ingest.go @@ -10,8 +10,8 @@ import ( "strings" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "stream.place/streamplace/pkg/aqtime" "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/log" @@ -51,12 +51,12 @@ func (mm *MediaManager) MP4Ingest(ctx context.Context, input io.Reader, ms Media go mm.HandleKeyRevocation(ctx, ms, pipeline) - if err := pipeline.SetState(gst.StatePlaying); err != nil { - return err + if pipeline.SetState(gst.StatePlaying) == gst.StateChangeFailure { + return fmt.Errorf("error setting MP4Ingest pipeline to playing") } defer func() { - if err := pipeline.SetState(gst.StateNull); err != nil { - log.Error(ctx, "error setting pipeline to null state", "error", err) + if pipeline.SetState(gst.StateNull) == gst.StateChangeFailure { + log.Error(ctx, "error setting pipeline to null state") } }() @@ -104,7 +104,7 @@ func rejectMatroska(input io.Reader) (io.Reader, error) { return br, nil } -func buildMP4IngestPipeline(ctx context.Context, input io.Reader, signerElem *gst.Element, streamer string) (*gst.Pipeline, error) { +func buildMP4IngestPipeline(ctx context.Context, input io.Reader, signerElem gst.Element, streamer string) (gst.Pipeline, error) { input, err := rejectMatroska(input) if err != nil { return nil, err @@ -125,35 +125,39 @@ func buildMP4IngestPipeline(ctx context.Context, input io.Reader, signerElem *gs "demux. ! " + constants.Queue2Big + " ! h264parse name=videoout", "demux. ! " + constants.Queue2Big + " ! fdkaacdec ! audioresample ! opusenc name=audioenc", } - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) + launched, err := gst.ParseLaunch(strings.Join(pipelineSlice, "\n")) if err != nil { return nil, fmt.Errorf("error creating MP4Ingest pipeline: %w", err) } - srcele, err := pipeline.GetElementByName("streamsrc") - if err != nil { - return nil, err + pipeline, ok := launched.(gst.Pipeline) + if !ok { + return nil, fmt.Errorf("error creating MP4Ingest pipeline: parsed description is not a pipeline") } - app.SrcFromElement(srcele).SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: ReaderNeedDataIncremental(ctx, input), + srcele := pipeline.GetByName("streamsrc") + if srcele == nil { + return nil, fmt.Errorf("streamsrc element not found") + } + srcele.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: ReaderNeedDataIncremental(ctx, input), }) - parseEle, err := pipeline.GetElementByName("videoout") - if err != nil { - return nil, err + parseEle := pipeline.GetByName("videoout") + if parseEle == nil { + return nil, fmt.Errorf("videoout element not found") } // Only an IDR starts a segment (see installIDRKeyframeProbe). installIDRKeyframeProbe(ctx, parseEle.GetStaticPad("src"), streamer) - if err := pipeline.Add(signerElem); err != nil { - return nil, err + if !pipeline.Add(signerElem) { + return nil, fmt.Errorf("failed to add signer element to pipeline") } - if err := parseEle.Link(signerElem); err != nil { - return nil, err + if !parseEle.Link(signerElem) { + return nil, fmt.Errorf("failed to link h264parse to signer element") } - audioenc, err := pipeline.GetElementByName("audioenc") - if err != nil { - return nil, err + audioenc := pipeline.GetByName("audioenc") + if audioenc == nil { + return nil, fmt.Errorf("audioenc element not found") } - if err := audioenc.Link(signerElem); err != nil { - return nil, err + if !audioenc.Link(signerElem) { + return nil, fmt.Errorf("failed to link opusenc to signer element") } return pipeline, nil } diff --git a/pkg/media/muxl_segment.go b/pkg/media/muxl_segment.go index 112c2cbad..13721306b 100644 --- a/pkg/media/muxl_segment.go +++ b/pkg/media/muxl_segment.go @@ -7,8 +7,8 @@ import ( "io" "sort" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/muxl" @@ -29,7 +29,7 @@ type SignSegmentStreamFunc func(ctx context.Context, input io.Reader, eventCh ch // is exactly what gets stored, verified, and replicated; no flat MP4 is // produced here. Presentation headers are synthesized downstream (ValidateMP4 // / playback) only when needed. -func MuxlSignSegmentElem(ctx context.Context, cli *config.CLI, ms MediaSigner, onSegment func(ctx context.Context, segment []byte) error) (*gst.Element, error) { +func MuxlSignSegmentElem(ctx context.Context, cli *config.CLI, ms MediaSigner, onSegment func(ctx context.Context, segment []byte) error) (gst.Element, error) { elem, _, err := muxlSignSegmentElem(ctx, cli, ms.SignSegmentStream, onSegment) return elem, err } @@ -40,22 +40,22 @@ func MuxlSignSegmentElem(ctx context.Context, cli *config.CLI, ms MediaSigner, o // has finished and the event loop has emptied). The isolated ingest worker waits // on it to guarantee all segment frames are flushed before it signals a clean // end-of-stream. -func muxlSignSegmentElem(ctx context.Context, cli *config.CLI, signStream SignSegmentStreamFunc, onSegment func(ctx context.Context, segment []byte) error) (*gst.Element, <-chan struct{}, error) { +func muxlSignSegmentElem(ctx context.Context, cli *config.CLI, signStream SignSegmentStreamFunc, onSegment func(ctx context.Context, segment []byte) error) (gst.Element, <-chan struct{}, error) { ctx = log.WithLogValues(ctx, "func", "MuxlSignSegmentElem") - bin := gst.NewBin("muxl-segment-bin") - elem, err := gst.NewElementWithProperties("mp4mux", map[string]any{ + bin := gst.NewBin("muxl-segment-bin").(gst.Bin) + elem := gst.ElementFactoryMakeWithProperties("mp4mux", map[string]any{ "name": "fmp4mux", - "fragment-mode": 0, - "fragment-duration": 1, + "fragment-mode": int32(0), + "fragment-duration": uint32(1), }) - if err != nil { - return nil, nil, err + if elem == nil { + return nil, nil, fmt.Errorf("failed to create mp4mux element") } - if err := bin.Add(elem); err != nil { - return nil, nil, fmt.Errorf("failed to add mp4mux to bin: %w", err) + if !bin.Add(elem) { + return nil, nil, fmt.Errorf("failed to add mp4mux to bin") } - videoPad := elem.GetRequestPad("video_%u") + videoPad := elem.RequestPadSimple("video_%u") if videoPad == nil { return nil, nil, fmt.Errorf("failed to get video pad") } @@ -63,7 +63,7 @@ func muxlSignSegmentElem(ctx context.Context, cli *config.CLI, signStream SignSe if videoGhost == nil { return nil, nil, fmt.Errorf("failed to create video ghost pad") } - audioPad := elem.GetRequestPad("audio_%u") + audioPad := elem.RequestPadSimple("audio_%u") if audioPad == nil { return nil, nil, fmt.Errorf("failed to get audio pad") } @@ -71,10 +71,10 @@ func muxlSignSegmentElem(ctx context.Context, cli *config.CLI, signStream SignSe if audioGhost == nil { return nil, nil, fmt.Errorf("failed to create audio ghost pad") } - if ok := bin.AddPad(videoGhost.Pad); !ok { + if ok := bin.AddPad(videoGhost); !ok { return nil, nil, fmt.Errorf("failed to add video ghost pad to bin") } - if ok := bin.AddPad(audioGhost.Pad); !ok { + if ok := bin.AddPad(audioGhost); !ok { return nil, nil, fmt.Errorf("failed to add audio ghost pad to bin") } @@ -84,18 +84,18 @@ func muxlSignSegmentElem(ctx context.Context, cli *config.CLI, signStream SignSe // harmless for a live source arriving at 1x, but it throttled tests/replays // and kept the graph's queues near-full for no benefit. Every other appsink // in the tree already sets this. - appsink, err := gst.NewElementWithProperties("appsink", map[string]any{ + appsink := gst.ElementFactoryMakeWithProperties("appsink", map[string]any{ "name": "muxl-appsink", "sync": false, }) - if err != nil { - return nil, nil, fmt.Errorf("failed to create appsink element: %w", err) + if appsink == nil { + return nil, nil, fmt.Errorf("failed to create appsink element") } - if err := bin.Add(appsink); err != nil { - return nil, nil, fmt.Errorf("failed to add appsink to bin: %w", err) + if !bin.Add(appsink) { + return nil, nil, fmt.Errorf("failed to add appsink to bin") } - if err := elem.Link(appsink); err != nil { - return nil, nil, fmt.Errorf("failed to link mp4mux to appsink: %w", err) + if !elem.Link(appsink) { + return nil, nil, fmt.Errorf("failed to link mp4mux to appsink") } r, w := io.Pipe() @@ -140,12 +140,11 @@ func muxlSignSegmentElem(ctx context.Context, cli *config.CLI, signStream SignSe } }() - sink := app.SinkFromElement(appsink) - sink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, w), + appsink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, w), }) - return bin.Element, done, nil + return bin, done, nil } // concatTracksSorted joins the per-track canonical segment bytes for one GoP diff --git a/pkg/media/packetize.go b/pkg/media/packetize.go index 187cc8ab9..79231abc3 100644 --- a/pkg/media/packetize.go +++ b/pkg/media/packetize.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/google/uuid" "stream.place/streamplace/pkg/bus" "stream.place/streamplace/pkg/config" @@ -77,19 +77,22 @@ func Packetize(ctx context.Context, cli *config.CLI, seg *bus.Seg) (*bus.Packeti fmt.Sprintf("%s name=audioparse ! opusparse ! appsink sync=false name=audioappsink", constants.Queue2Big), } - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) + pipelineElem, err := gst.ParseLaunch(strings.Join(pipelineSlice, "\n")) if err != nil { return nil, fmt.Errorf("failed to create GStreamer pipeline: %w", err) //nolint:all } + pipeline, ok := pipelineElem.(gst.Pipeline) + if !ok { + return nil, fmt.Errorf("failed to create GStreamer pipeline: not a pipeline") + } demuxBin, err := ConcatDemuxBin(ctx, seg, true) if err != nil { return nil, fmt.Errorf("failed to create concat bin: %w", err) } - err = pipeline.Add(demuxBin.Element) - if err != nil { - return nil, fmt.Errorf("failed to add demux bin to bin: %w", err) + if !pipeline.Add(demuxBin) { + return nil, fmt.Errorf("failed to add demux bin to bin") } demuxBinPadVideoSrc := demuxBin.GetStaticPad("video_0") @@ -102,14 +105,14 @@ func Packetize(ctx context.Context, cli *config.CLI, seg *bus.Seg) (*bus.Packeti return nil, fmt.Errorf("failed to get demux bin audio src pad") } - videoParse, err := pipeline.GetElementByName("videoparse") - if err != nil { - return nil, fmt.Errorf("failed to get video parse element: %w", err) + videoParse := pipeline.GetByName("videoparse") + if videoParse == nil { + return nil, fmt.Errorf("failed to get video parse element") } - audioParse, err := pipeline.GetElementByName("audioparse") - if err != nil { - return nil, fmt.Errorf("failed to get audio parse element: %w", err) + audioParse := pipeline.GetByName("audioparse") + if audioParse == nil { + return nil, fmt.Errorf("failed to get audio parse element") } linked := demuxBinPadVideoSrc.Link(videoParse.GetStaticPad("sink")) @@ -122,18 +125,12 @@ func Packetize(ctx context.Context, cli *config.CLI, seg *bus.Seg) (*bus.Packeti return nil, fmt.Errorf("failed to link demux bin audio src pad to audio parse element: %v", linked) } - videoSink, err := pipeline.GetElementByName("videoappsink") - if err != nil { - return nil, fmt.Errorf("failed to get video appsink element: %w", err) - } + videoSink := pipeline.GetByName("videoappsink") if videoSink == nil { return nil, fmt.Errorf("failed to get video appsink element") } - audioSink, err := pipeline.GetElementByName("audioappsink") - if err != nil { - return nil, fmt.Errorf("failed to get audio appsink element: %w", err) - } + audioSink := pipeline.GetByName("audioappsink") if audioSink == nil { return nil, fmt.Errorf("failed to get audio appsink element") } @@ -154,9 +151,8 @@ func Packetize(ctx context.Context, cli *config.CLI, seg *bus.Seg) (*bus.Packeti // ride with and is dropped. var pendingVideo []byte - videoappsink := app.SinkFromElement(videoSink) - videoappsink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { + videoSink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { return gst.FlowEOS @@ -167,7 +163,13 @@ func Packetize(ctx context.Context, cli *config.CLI, seg *bus.Seg) (*bus.Packeti return gst.FlowError } - samples := buffer.Bytes() + info, ok := buffer.Map(gst.MapRead) + if !ok { + log.Error(ctx, "failed to map video buffer") + return gst.FlowError + } + defer info.Unmap() + samples := info.Data() if !hasVideoSlice(samples) { pendingVideo = append(pendingVideo, samples...) @@ -176,22 +178,24 @@ func Packetize(ctx context.Context, cli *config.CLI, seg *bus.Seg) (*bus.Packeti if pendingVideo != nil { samples = append(pendingVideo, samples...) pendingVideo = nil + } else { + // the mapping is gone once this callback returns + samples = bytes.Clone(samples) } videoOutput = append(videoOutput, newRawSample(samples, buffer)) return gst.FlowOK }, - EOSFunc: func(sink *app.Sink) { + EOS: func(sink app.AppSink) { log.Debug(ctx, "videoappsink EOSFunc") }, }) segDur := time.Duration(0) - audioappsink := app.SinkFromElement(audioSink) - audioappsink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { + audioSink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { log.Warn(ctx, "audioappsink NewSampleFunc EOS") @@ -203,15 +207,18 @@ func Packetize(ctx context.Context, cli *config.CLI, seg *bus.Seg) (*bus.Packeti return gst.FlowError } - samples := buffer.Bytes() + samples, err := copyBufferData(buffer) + if err != nil { + log.Error(ctx, "failed to read audio buffer", "error", err) + return gst.FlowError + } // log.Warn(ctx, "audioappsink NewSampleFunc", "sample", len(samples)) audioOutput = append(audioOutput, newRawSample(samples, buffer)) - clockTime := buffer.Duration() - dur := clockTime.AsDuration() - if dur != nil { - segDur += *dur + dur, ok := clockTimeDuration(buffer.Duration()) + if ok { + segDur += dur } else { log.Error(ctx, "no audio duration", "samples", len(samples)) return gst.FlowError @@ -219,7 +226,7 @@ func Packetize(ctx context.Context, cli *config.CLI, seg *bus.Seg) (*bus.Packeti return gst.FlowOK }, - EOSFunc: func(sink *app.Sink) { + EOS: func(sink app.AppSink) { log.Debug(ctx, "audioappsink EOSFunc") }, }) @@ -233,19 +240,16 @@ func Packetize(ctx context.Context, cli *config.CLI, seg *bus.Seg) (*bus.Packeti busErr <- err }() - err = pipeline.SetState(gst.StatePlaying) - if err != nil { - return nil, fmt.Errorf("failed to set pipeline to playing state: %w", err) + if ret := pipeline.SetState(gst.StatePlaying); ret == gst.StateChangeFailure { + return nil, fmt.Errorf("failed to set pipeline to playing state") } defer func() { - err = pipeline.SetState(gst.StateNull) - if err != nil { - log.Error(ctx, "failed to set pipeline to null state", "error", err) + if ret := pipeline.SetState(gst.StateNull); ret == gst.StateChangeFailure { + log.Error(ctx, "failed to set pipeline to null state") } - err = pipeline.Remove(demuxBin.Element) - if err != nil { - log.Error(ctx, "failed to remove demux bin from bin", "error", err) + if !pipeline.Remove(demuxBin) { + log.Error(ctx, "failed to remove demux bin from bin") } }() @@ -285,13 +289,13 @@ type rawSample struct { func newRawSample(data []byte, buffer *gst.Buffer) rawSample { rs := rawSample{data: data} - if ts := buffer.DecodingTimestamp().AsDuration(); ts != nil { - rs.ts, rs.hasTS = *ts, true - } else if pts := buffer.PresentationTimestamp().AsDuration(); pts != nil { - rs.ts, rs.hasTS = *pts, true + if ts, ok := clockTimeDuration(buffer.DTS()); ok { + rs.ts, rs.hasTS = ts, true + } else if pts, ok := clockTimeDuration(buffer.PTS()); ok { + rs.ts, rs.hasTS = pts, true } - if dur := buffer.Duration().AsDuration(); dur != nil { - rs.bufDur, rs.hasDur = *dur, true + if dur, ok := clockTimeDuration(buffer.Duration()); ok { + rs.bufDur, rs.hasDur = dur, true } return rs } diff --git a/pkg/media/packetize_test.go b/pkg/media/packetize_test.go index 033c6862a..376f43534 100644 --- a/pkg/media/packetize_test.go +++ b/pkg/media/packetize_test.go @@ -12,8 +12,9 @@ import ( "testing" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-glib/pkg/glib/v2" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" "stream.place/streamplace/pkg/bus" @@ -119,13 +120,14 @@ func makeTrailingCaptionSEIFlatMP4(t *testing.T, ctx context.Context, frames, se } aus := []au{} var vcaps *gst.Caps - encPipeline, err := gst.NewPipelineFromString(fmt.Sprintf( + encElem, err := gst.ParseLaunch(fmt.Sprintf( "videotestsrc num-buffers=%d ! video/x-raw,width=320,height=240,framerate=30/1 ! x264enc tune=zerolatency speed-preset=ultrafast key-int-max=30 ! h264parse ! video/x-h264,stream-format=avc,alignment=au ! appsink name=sink", frames)) require.NoError(t, err) - sinkEle, err := encPipeline.GetElementByName("sink") - require.NoError(t, err) - app.SinkFromElement(sinkEle).SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { + encPipeline := encElem.(gst.Pipeline) + sinkEle := encPipeline.GetByName("sink") + require.NotNil(t, sinkEle) + sinkEle.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { return gst.FlowEOS @@ -134,19 +136,23 @@ func makeTrailingCaptionSEIFlatMP4(t *testing.T, ctx context.Context, frames, se vcaps = sample.GetCaps() } buffer := sample.GetBuffer() + data, err := copyBufferData(buffer) + if err != nil { + return gst.FlowError + } aus = append(aus, au{ - data: append([]byte{}, buffer.Bytes()...), - pts: gst.ClockTime(buffer.PresentationTimestamp()), - dur: gst.ClockTime(buffer.Duration()), + data: data, + pts: buffer.PTS(), + dur: buffer.Duration(), }) return gst.FlowOK }, }) busErr := make(chan error, 1) go func() { busErr <- HandleBusMessages(ctx, encPipeline) }() - require.NoError(t, encPipeline.SetState(gst.StatePlaying)) + require.NotEqual(t, gst.StateChangeFailure, encPipeline.SetState(gst.StatePlaying)) require.NoError(t, <-busErr) - require.NoError(t, encPipeline.SetState(gst.StateNull)) + require.NotEqual(t, gst.StateChangeFailure, encPipeline.SetState(gst.StateNull)) require.Len(t, aus, frames) require.NotNil(t, vcaps) @@ -181,41 +187,42 @@ func makeTrailingCaptionSEIFlatMP4(t *testing.T, ctx context.Context, frames, se // Pad names are explicit: the appsrc's caps aren't known at parse time, so // without them gst-parse guesses which mux request pad to link (and // guesses wrong). - muxPipeline, err := gst.NewPipelineFromString(strings.Join([]string{ + muxElem, err := gst.ParseLaunch(strings.Join([]string{ "mp4mux name=mux fragment-duration=500 ! appsink name=sink", "appsrc name=vsrc format=time ! mux.video_0", fmt.Sprintf("audiotestsrc num-buffers=%d samplesperbuffer=1024 ! audio/x-raw,rate=48000,channels=2 ! audioconvert ! opusenc ! mux.audio_0", audioBuffers), }, "\n")) require.NoError(t, err) - vsrcEle, err := muxPipeline.GetElementByName("vsrc") - require.NoError(t, err) - require.NoError(t, vsrcEle.SetProperty("caps", vcaps)) + muxPipeline := muxElem.(gst.Pipeline) + vsrcEle := muxPipeline.GetByName("vsrc") + require.NotNil(t, vsrcEle) + vsrcEle.SetObjectProperty("caps", vcaps) idx := 0 - app.SrcFromElement(vsrcEle).SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: func(self *app.Source, _ uint) { + vsrcEle.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: func(self app.AppSrc, _ uint) { if idx >= len(aus) { - self.EndStream() + self.EndOfStream() return } a := aus[idx] idx++ - buffer := gst.NewBufferFromBytes(a.data) - buffer.SetPresentationTimestamp(a.pts) + buffer := gst.NewBufferWrappedBytes(glib.NewBytes(a.data)) + buffer.SetPTS(a.pts) buffer.SetDuration(a.dur) self.PushBuffer(buffer) }, }) - outSinkEle, err := muxPipeline.GetElementByName("sink") - require.NoError(t, err) + outSinkEle := muxPipeline.GetByName("sink") + require.NotNil(t, outSinkEle) var out bytes.Buffer - app.SinkFromElement(outSinkEle).SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, &out), + outSinkEle.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, &out), }) busErr2 := make(chan error, 1) go func() { busErr2 <- HandleBusMessages(ctx, muxPipeline) }() - require.NoError(t, muxPipeline.SetState(gst.StatePlaying)) + require.NotEqual(t, gst.StateChangeFailure, muxPipeline.SetState(gst.StatePlaying)) require.NoError(t, <-busErr2) - require.NoError(t, muxPipeline.SetState(gst.StateNull)) + require.NotEqual(t, gst.StateChangeFailure, muxPipeline.SetState(gst.StateNull)) require.NotEmpty(t, out.Bytes()) return out.Bytes(), seiCount } diff --git a/pkg/media/random_access_src.go b/pkg/media/random_access_src.go index 6d3c9cbeb..905077cdb 100644 --- a/pkg/media/random_access_src.go +++ b/pkg/media/random_access_src.go @@ -8,8 +8,8 @@ import ( "io" "os" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "stream.place/streamplace/pkg/log" ) @@ -80,33 +80,33 @@ func newReadTracer(ctx context.Context) func(pos, size int64) { // The context is captured for the bin's lifetime. Callers MUST cancel ctx // when they're done with the bin so any in-flight ReadAt — particularly the // S3 case, where ReadAt is an HTTP request — can abort cleanly. -func RandomAccessSrcBin(ctx context.Context, name string, src io.ReaderAt, size int64) (*gst.Bin, error) { +func RandomAccessSrcBin(ctx context.Context, name string, src io.ReaderAt, size int64) (gst.Bin, error) { ctx = log.WithLogValues(ctx, "func", "RandomAccessSrcBin") if size < 0 { return nil, fmt.Errorf("size must be non-negative, got %d", size) } - bin := gst.NewBin(name + "-bin") + bin := gst.NewBin(name + "-bin").(gst.Bin) - appSrc, err := gst.NewElementWithProperties("appsrc", map[string]interface{}{ + appSrc := gst.ElementFactoryMakeWithProperties("appsrc", map[string]interface{}{ "name": name, }) - if err != nil { - return nil, fmt.Errorf("create appsrc: %w", err) + if appSrc == nil { + return nil, fmt.Errorf("create appsrc") } - if err := bin.Add(appSrc); err != nil { - return nil, fmt.Errorf("add appsrc to bin: %w", err) + if !bin.Add(appSrc) { + return nil, fmt.Errorf("add appsrc to bin") } - source := app.SrcFromElement(appSrc) + source := appSrc.(app.AppSrc) // appsrc defaults to GST_FORMAT_BYTES, which is what we want for byte- // offset seeking. SetSize lets downstream elements (parsebin, qtdemux) // query a duration in bytes and seek to the end of the stream. source.SetSize(size) source.SetStreamType(app.AppStreamTypeRandomAccess) - // pos tracks where the next NeedDataFunc read should start. eos is a + // pos tracks where the next NeedData read should start. eos is a // local guard against pushing past end-of-stream after we've already - // emitted it; SeekDataFunc clears it so post-EOS seeks (which qtdemux + // emitted it; SeekData clears it so post-EOS seeks (which qtdemux // performs after parsing moov) can resume reads. // // appsrc serializes its own callbacks, so no mutex is needed. @@ -115,10 +115,10 @@ func RandomAccessSrcBin(ctx context.Context, name string, src io.ReaderAt, size trace := newReadTracer(ctx) - source.SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: func(self *app.Source, length uint) { + source.SetCallbacks(&app.AppSrcCallbacks{ + NeedData: func(self app.AppSrc, length uint) { if ctx.Err() != nil { - self.EndStream() + self.EndOfStream() return } if eos { @@ -126,7 +126,7 @@ func RandomAccessSrcBin(ctx context.Context, name string, src io.ReaderAt, size } remaining := size - pos if remaining <= 0 { - self.EndStream() + self.EndOfStream() eos = true return } @@ -141,9 +141,12 @@ func RandomAccessSrcBin(ctx context.Context, name string, src io.ReaderAt, size trace(pos, n) read, err := src.ReadAt(buf, pos) if read > 0 { - gbuf := gst.NewBufferWithSize(int64(read)) - gbuf.Map(gst.MapWrite).WriteData(buf[:read]) - gbuf.Unmap() + gbuf, copyErr := newBufferFromBytes(buf[:read]) + if copyErr != nil { + self.PostMessage(gst.NewMessageError(self, "copy input", copyErr)) + eos = true + return + } if ret := self.PushBuffer(gbuf); ret != gst.FlowOK { log.Debug(ctx, "RandomAccessSrcBin: push buffer non-OK", "ret", ret.String()) } @@ -151,16 +154,16 @@ func RandomAccessSrcBin(ctx context.Context, name string, src io.ReaderAt, size } if err != nil && !errors.Is(err, io.EOF) { log.Error(ctx, "RandomAccessSrcBin: read failed", "offset", pos, "error", err) - self.Error("read failed", err) + self.PostMessage(gst.NewMessageError(self, "read failed", err)) eos = true return } if errors.Is(err, io.EOF) || pos >= size { - self.EndStream() + self.EndOfStream() eos = true } }, - SeekDataFunc: func(self *app.Source, offset uint64) bool { + SeekData: func(self app.AppSrc, offset uint64) bool { if ctx.Err() != nil { return false } @@ -182,7 +185,7 @@ func RandomAccessSrcBin(ctx context.Context, name string, src io.ReaderAt, size if ghost == nil { return nil, fmt.Errorf("create ghost pad") } - if !bin.AddPad(ghost.Pad) { + if !bin.AddPad(ghost) { return nil, fmt.Errorf("add ghost pad to bin") } diff --git a/pkg/media/random_access_src_test.go b/pkg/media/random_access_src_test.go index 526d9bf2b..f4d57b05c 100644 --- a/pkg/media/random_access_src_test.go +++ b/pkg/media/random_access_src_test.go @@ -8,8 +8,8 @@ import ( "os" "testing" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/stretchr/testify/require" "stream.place/streamplace/pkg/log" ) @@ -27,19 +27,18 @@ func TestRandomAccessSrcBin_Passthrough(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, fixture) - pipeline, err := gst.NewPipeline("ra-src-passthrough") - require.NoError(t, err) + pipeline := gst.NewPipeline("ra-src-passthrough").(gst.Pipeline) srcBin, err := RandomAccessSrcBin(ctx, "ra-src", bytes.NewReader(fixture), int64(len(fixture))) require.NoError(t, err) - require.NoError(t, pipeline.Add(srcBin.Element)) + require.True(t, pipeline.Add(srcBin)) - sinkEle, err := gst.NewElementWithProperties("appsink", map[string]interface{}{ + sinkEle := gst.ElementFactoryMakeWithProperties("appsink", map[string]interface{}{ "name": "ra-sink", "sync": false, }) - require.NoError(t, err) - require.NoError(t, pipeline.Add(sinkEle)) + require.NotNil(t, sinkEle) + require.True(t, pipeline.Add(sinkEle)) ghostPad := srcBin.GetStaticPad("src") require.NotNil(t, ghostPad) @@ -48,17 +47,17 @@ func TestRandomAccessSrcBin_Passthrough(t *testing.T) { require.Equal(t, gst.PadLinkOK, ghostPad.Link(sinkPad)) out := &bytes.Buffer{} - sink := app.SinkFromElement(sinkEle) - sink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, out), + sink := sinkEle.(app.AppSink) + sink.SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, out), }) errCh := make(chan error, 1) go func() { errCh <- HandleBusMessages(ctx, pipeline) }() - require.NoError(t, pipeline.SetState(gst.StatePlaying)) + require.NotEqual(t, gst.StateChangeFailure, pipeline.SetState(gst.StatePlaying)) require.NoError(t, <-errCh) - require.NoError(t, pipeline.BlockSetState(gst.StateNull)) + require.Equal(t, gst.StateChangeSuccess, pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone)) require.Equal(t, fixture, out.Bytes()) }) @@ -85,39 +84,38 @@ func TestRandomAccessSrcBin_Seek(t *testing.T) { size := int64(len(fixture)) check := func(name string, offset, length int64) { - pipeline, err := gst.NewPipeline("ra-src-seek-" + name) - require.NoError(t, err) + pipeline := gst.NewPipeline("ra-src-seek-" + name).(gst.Pipeline) srcBin, err := RandomAccessSrcBin(ctx, "ra-src", reader, size) require.NoError(t, err) - require.NoError(t, pipeline.Add(srcBin.Element)) + require.True(t, pipeline.Add(srcBin)) - sinkEle, err := gst.NewElementWithProperties("appsink", map[string]interface{}{ + sinkEle := gst.ElementFactoryMakeWithProperties("appsink", map[string]interface{}{ "name": "ra-sink", "sync": false, }) - require.NoError(t, err) - require.NoError(t, pipeline.Add(sinkEle)) + require.NotNil(t, sinkEle) + require.True(t, pipeline.Add(sinkEle)) require.Equal(t, gst.PadLinkOK, srcBin.GetStaticPad("src").Link(sinkEle.GetStaticPad("sink"))) out := &bytes.Buffer{} - sink := app.SinkFromElement(sinkEle) - sink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, out), + sink := sinkEle.(app.AppSink) + sink.SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, out), }) errCh := make(chan error, 1) go func() { errCh <- HandleBusMessages(ctx, pipeline) }() - require.NoError(t, pipeline.SetState(gst.StatePaused)) + require.NotEqual(t, gst.StateChangeFailure, pipeline.SetState(gst.StatePaused)) // Issue a byte-format seek into the desired range. - ok := pipeline.SeekSimple(offset, gst.FormatBytes, gst.SeekFlagFlush) + ok := pipeline.SeekSimple(gst.FormatBytes, gst.SeekFlagFlush, offset) require.True(t, ok, "%s: seek to %d failed", name, offset) - require.NoError(t, pipeline.SetState(gst.StatePlaying)) + require.NotEqual(t, gst.StateChangeFailure, pipeline.SetState(gst.StatePlaying)) require.NoError(t, <-errCh) - require.NoError(t, pipeline.BlockSetState(gst.StateNull)) + require.Equal(t, gst.StateChangeSuccess, pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone)) got := out.Bytes() require.GreaterOrEqual(t, len(got), int(length), "%s: expected at least %d bytes, got %d", name, length, len(got)) diff --git a/pkg/media/rtmp_ingest.go b/pkg/media/rtmp_ingest.go index 3d1472733..2e5fb1d80 100644 --- a/pkg/media/rtmp_ingest.go +++ b/pkg/media/rtmp_ingest.go @@ -9,7 +9,7 @@ import ( "time" "github.com/bluenviron/gortsplib/v5/pkg/format" - "github.com/go-gst/go-gst/gst" + "github.com/go-gst/go-gst/pkg/gst" "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/muxl" @@ -97,7 +97,7 @@ func RTMPIngestUnpublished(ctx context.Context, cli *config.CLI, rtmpURL string, // newRTMPIngestPipeline pulls rtmpURL with rtmp2src and feeds its H.264 and // AAC tracks into the signer element. -func newRTMPIngestPipeline(rtmpURL string, signer *gst.Element) (*gst.Pipeline, error) { +func newRTMPIngestPipeline(rtmpURL string, signer gst.Element) (gst.Pipeline, error) { // Mint the source audio: RTMP/FLV audio is already AAC, so pass it through // (aacparse) rather than transcoding to Opus. The validate path completes // each segment to also carry Opus when a consumer (WebRTC) needs it — so @@ -107,51 +107,50 @@ func newRTMPIngestPipeline(rtmpURL string, signer *gst.Element) (*gst.Pipeline, "demux.audio ! queue ! aacparse name=audioenc", "demux.video ! queue ! h264parse name=parse", } - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) + pipelineElem, err := gst.ParseLaunch(strings.Join(pipelineSlice, "\n")) if err != nil { return nil, fmt.Errorf("error creating RTMPIngest pipeline: %w", err) } + pipeline, ok := pipelineElem.(gst.Pipeline) + if !ok { + return nil, fmt.Errorf("error creating RTMPIngest pipeline: not a pipeline") + } - parseEle, err := pipeline.GetElementByName("parse") - if err != nil { - return nil, err + parseEle := pipeline.GetByName("parse") + if parseEle == nil { + return nil, fmt.Errorf("failed to get parse element") } - err = pipeline.Add(signer) - if err != nil { - return nil, err + if !pipeline.Add(signer) { + return nil, fmt.Errorf("failed to add signer to pipeline") } - err = parseEle.Link(signer) - if err != nil { - return nil, err + if !parseEle.Link(signer) { + return nil, fmt.Errorf("failed to link parse to signer") } - audioenc, err := pipeline.GetElementByName("audioenc") - if err != nil { - return nil, err + audioenc := pipeline.GetByName("audioenc") + if audioenc == nil { + return nil, fmt.Errorf("failed to get audioenc element") } - err = audioenc.Link(signer) - if err != nil { - return nil, err + if !audioenc.Link(signer) { + return nil, fmt.Errorf("failed to link audioenc to signer") } return pipeline, nil } // runRTMPIngestPipeline plays pipeline until it ends or ctx does. -func runRTMPIngestPipeline(ctx context.Context, pipeline *gst.Pipeline) error { +func runRTMPIngestPipeline(ctx context.Context, pipeline gst.Pipeline) error { busErr := make(chan error, 1) go func() { busErr <- HandleBusMessages(ctx, pipeline) }() - err := pipeline.SetState(gst.StatePlaying) - if err != nil { - return err + if ret := pipeline.SetState(gst.StatePlaying); ret == gst.StateChangeFailure { + return fmt.Errorf("failed to set pipeline to playing state") } defer func() { - err := pipeline.SetState(gst.StateNull) - if err != nil { - log.Error(ctx, "error setting pipeline to null state", "error", err) + if ret := pipeline.SetState(gst.StateNull); ret == gst.StateChangeFailure { + log.Error(ctx, "error setting pipeline to null state") } }() diff --git a/pkg/media/rtmp_push.go b/pkg/media/rtmp_push.go index 066e6811a..9dfa262aa 100644 --- a/pkg/media/rtmp_push.go +++ b/pkg/media/rtmp_push.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/muxl" @@ -127,14 +127,18 @@ func (mm *MediaManager) runRTMPPushPipeline(ctx context.Context, source io.Reade fmt.Sprintf("%s name=audioqueue ! aacparse ! muxer.audio", constants.Queue2Big), } - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) + pipelineElem, err := gst.ParseLaunch(strings.Join(pipelineSlice, "\n")) if err != nil { return fmt.Errorf("failed to create GStreamer pipeline: %w", err) //nolint:all } + pipeline, ok := pipelineElem.(gst.Pipeline) + if !ok { + return fmt.Errorf("failed to create GStreamer pipeline: not a pipeline") + } - rtmp2sink, err := pipeline.GetElementByName("rtmp2sink") - if err != nil { - return fmt.Errorf("failed to get rtmp2sink element from pipeline: %w", err) + rtmp2sink := pipeline.GetByName("rtmp2sink") + if rtmp2sink == nil { + return fmt.Errorf("failed to get rtmp2sink element from pipeline") } // rtmp2sink can't speak rtmps and doesn't do TLS, so we always point it at a @@ -157,9 +161,7 @@ func (mm *MediaManager) runRTMPPushPipeline(ctx context.Context, source io.Reade } local := fmt.Sprintf("rtmp://%s%s", localAddr, u.Path) log.Debug(ctx, "running forwarder", "localAddr", local) - if err := rtmp2sink.SetProperty("location", local); err != nil { - return fmt.Errorf("failed to set rtmp2sink location: %w", err) - } + rtmp2sink.SetObjectProperty("location", local) // Poll the sink and report "active" once the destination acks bytes; back off // once it's flowing so we don't spam the status channel. @@ -170,11 +172,7 @@ func (mm *MediaManager) runRTMPPushPipeline(ctx context.Context, source io.Reade case <-ctx.Done(): return case <-time.After(pollFreq): - prop, err := rtmp2sink.GetProperty("stats") - if err != nil { - log.Error(ctx, "error getting rtmp2sink stats", "error", err) - continue - } + prop := rtmp2sink.ObjectProperty("stats") if prop == nil { log.Error(ctx, "failed to get rtmp2sink stats", "prop", prop) continue @@ -184,14 +182,9 @@ func (mm *MediaManager) runRTMPPushPipeline(ctx context.Context, source io.Reade log.Error(ctx, "failed to convert rtmp2sink stats", "prop", prop) continue } - outBytesAcked, err := propVal.GetValue("out-bytes-acked") - if err != nil { - log.Error(ctx, "failed to get rtmp2sink out-bytes-acked", "error", err) - continue - } - outBytesAckedVal, ok := outBytesAcked.(uint64) + outBytesAckedVal, ok := propVal.GetUint64("out-bytes-acked") if !ok { - log.Error(ctx, "failed to convert rtmp2sink out-bytes-acked", "prop", prop) + log.Error(ctx, "failed to get rtmp2sink out-bytes-acked", "prop", prop) continue } if outBytesAckedVal > 0 { @@ -204,32 +197,32 @@ func (mm *MediaManager) runRTMPPushPipeline(ctx context.Context, source io.Reade } }() - muxlSrc, err := pipeline.GetElementByName("muxlsrc") - if err != nil { - return fmt.Errorf("failed to get appsrc element from pipeline: %w", err) + muxlSrc := pipeline.GetByName("muxlsrc") + if muxlSrc == nil { + return fmt.Errorf("failed to get appsrc element from pipeline") } - app.SrcFromElement(muxlSrc).SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: ReaderNeedDataIncremental(ctx, source), + muxlSrc.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: ReaderNeedDataIncremental(ctx, source), }) - videoQueue, err := pipeline.GetElementByName("videoqueue") - if err != nil { - return fmt.Errorf("failed to get video parse element from pipeline: %w", err) + videoQueue := pipeline.GetByName("videoqueue") + if videoQueue == nil { + return fmt.Errorf("failed to get video parse element from pipeline") } - audioQueue, err := pipeline.GetElementByName("audioqueue") - if err != nil { - return fmt.Errorf("failed to get audio parse element from pipeline: %w", err) + audioQueue := pipeline.GetByName("audioqueue") + if audioQueue == nil { + return fmt.Errorf("failed to get audio parse element from pipeline") } // qtdemux exposes its track pads only after parsing the moov, so link them // on pad-added: video → h264parse, audio → aacparse. - demux, err := pipeline.GetElementByName("demux") - if err != nil { - return fmt.Errorf("failed to get demux element from pipeline: %w", err) + demux := pipeline.GetByName("demux") + if demux == nil { + return fmt.Errorf("failed to get demux element from pipeline") } - if _, err := demux.Connect("pad-added", func(self *gst.Element, pad *gst.Pad) { + demux.ConnectPadAdded(func(self gst.Element, pad gst.Pad) { name := pad.GetName() - var sink *gst.Pad + var sink gst.Pad switch { case strings.HasPrefix(name, "video_"): sink = videoQueue.GetStaticPad("sink") @@ -242,9 +235,7 @@ func (mm *MediaManager) runRTMPPushPipeline(ctx context.Context, source io.Reade if linked := pad.Link(sink); linked != gst.PadLinkOK { log.Error(ctx, "failed to link demux pad", "name", name, "result", linked) } - }); err != nil { - return fmt.Errorf("failed to connect demux pad-added: %w", err) - } + }) errCh := make(chan error) go func() { @@ -253,15 +244,14 @@ func (mm *MediaManager) runRTMPPushPipeline(ctx context.Context, source io.Reade errCh <- err }() - err = pipeline.SetState(gst.StatePlaying) - if err != nil { - return fmt.Errorf("failed to set pipeline state to playing: %w", err) + if ret := pipeline.SetState(gst.StatePlaying); ret == gst.StateChangeFailure { + return fmt.Errorf("failed to set pipeline state to playing") } defer func() { log.Log(ctx, "shutting down RTMP push pipeline") - if err := pipeline.SetState(gst.StateNull); err != nil { - log.Error(ctx, "failed to set pipeline state to null", "error", err) + if ret := pipeline.SetState(gst.StateNull); ret == gst.StateChangeFailure { + log.Error(ctx, "failed to set pipeline state to null") } }() diff --git a/pkg/media/segment_conv.go b/pkg/media/segment_conv.go index e8b316ac7..033a20542 100644 --- a/pkg/media/segment_conv.go +++ b/pkg/media/segment_conv.go @@ -6,8 +6,8 @@ import ( "io" "strings" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "golang.org/x/sync/errgroup" "stream.place/streamplace/pkg/log" ) @@ -36,36 +36,40 @@ func MP4ToMPEGTSVideoMP4Audio(ctx context.Context, input io.Reader, videoOutput "demux.audio_0 ! parsebin ! queue name=audioqueue max-size-time=0 max-size-buffers=0 max-size-bytes=0", }, " ") - pipeline, err := gst.NewPipelineFromString(pipelineStr) + pipelineElem, err := gst.ParseLaunch(pipelineStr) if err != nil { return err } + pipeline, ok := pipelineElem.(gst.Pipeline) + if !ok { + return fmt.Errorf("failed to create pipeline: not a pipeline") + } - videomux, err := pipeline.GetElementByName("videomux") - if err != nil { - return err + videomux := pipeline.GetByName("videomux") + if videomux == nil { + return fmt.Errorf("failed to get video mux element") } - muxVideoSinkPad := videomux.GetRequestPad("sink_%d") + muxVideoSinkPad := videomux.RequestPadSimple("sink_%d") if muxVideoSinkPad == nil { return fmt.Errorf("failed to get video sink pad") } - audiomux, err := pipeline.GetElementByName("audiomux") - if err != nil { - return err + audiomux := pipeline.GetByName("audiomux") + if audiomux == nil { + return fmt.Errorf("failed to get audio mux element") } - muxAudioSinkPad := audiomux.GetRequestPad("audio_%u") + muxAudioSinkPad := audiomux.RequestPadSimple("audio_%u") if muxAudioSinkPad == nil { return fmt.Errorf("failed to get audio sink pad") } - videoQueue, err := pipeline.GetElementByName("videoqueue") - if err != nil { - return err + videoQueue := pipeline.GetByName("videoqueue") + if videoQueue == nil { + return fmt.Errorf("failed to get video queue element") } - audioQueue, err := pipeline.GetElementByName("audioqueue") - if err != nil { - return err + audioQueue := pipeline.GetByName("audioqueue") + if audioQueue == nil { + return fmt.Errorf("failed to get audio queue element") } videoQueueSrcPad := videoQueue.GetStaticPad("src") @@ -77,56 +81,52 @@ func MP4ToMPEGTSVideoMP4Audio(ctx context.Context, input io.Reader, videoOutput return fmt.Errorf("failed to get audio queue source pad") } - ok := videoQueueSrcPad.Link(muxVideoSinkPad) - if ok != gst.PadLinkOK { - return fmt.Errorf("failed to link video queue source pad to mux video sink pad: %v", ok) + linked := videoQueueSrcPad.Link(muxVideoSinkPad) + if linked != gst.PadLinkOK { + return fmt.Errorf("failed to link video queue source pad to mux video sink pad: %v", linked) } - ok = audioQueueSrcPad.Link(muxAudioSinkPad) - if ok != gst.PadLinkOK { - return fmt.Errorf("failed to link audio queue source pad to mux audio sink pad: %v", ok) + linked = audioQueueSrcPad.Link(muxAudioSinkPad) + if linked != gst.PadLinkOK { + return fmt.Errorf("failed to link audio queue source pad to mux audio sink pad: %v", linked) } // Get elements - appsrc, err := pipeline.GetElementByName("appsrc") - if err != nil { - return err + appsrc := pipeline.GetByName("appsrc") + if appsrc == nil { + return fmt.Errorf("failed to get appsrc element") } - videoappsink, err := pipeline.GetElementByName("videoappsink") - if err != nil { - return err + videoappsink := pipeline.GetByName("videoappsink") + if videoappsink == nil { + return fmt.Errorf("failed to get video appsink element") } - audioappsink, err := pipeline.GetElementByName("audioappsink") - if err != nil { - return err + audioappsink := pipeline.GetByName("audioappsink") + if audioappsink == nil { + return fmt.Errorf("failed to get audio appsink element") } - source := app.SrcFromElement(appsrc) - videoSink := app.SinkFromElement(videoappsink) - audioSink := app.SinkFromElement(audioappsink) - // Set up source callbacks - source.SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: ReaderNeedDataIncremental(ctx, input), - EnoughDataFunc: func(self *app.Source) { + appsrc.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: ReaderNeedDataIncremental(ctx, input), + EnoughData: func(self app.AppSrc) { // Nothing to do here }, - SeekDataFunc: func(self *app.Source, offset uint64) bool { + SeekData: func(self app.AppSrc, offset uint64) bool { return false // We don't support seeking }, }) // Set up sink callbacks - videoSink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, videoOutput), - NewPrerollFunc: func(self *app.Sink) gst.FlowReturn { + videoappsink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, videoOutput), + NewPreroll: func(self app.AppSink) gst.FlowReturn { return gst.FlowOK }, }) // Set up sink callbacks - audioSink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, audioOutput), - NewPrerollFunc: func(self *app.Sink) gst.FlowReturn { + audioappsink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, audioOutput), + NewPreroll: func(self app.AppSink) gst.FlowReturn { return gst.FlowOK }, }) @@ -143,18 +143,16 @@ func MP4ToMPEGTSVideoMP4Audio(ctx context.Context, input io.Reader, videoOutput }) // Start the pipeline - err = pipeline.SetState(gst.StatePlaying) - if err != nil { - return fmt.Errorf("failed to set pipeline state to playing: %w", err) + if ret := pipeline.SetState(gst.StatePlaying); ret == gst.StateChangeFailure { + return fmt.Errorf("failed to set pipeline state to playing") } // Wait for the pipeline to finish or context to be canceled <-ctx.Done() // Clean up - err = pipeline.SetState(gst.StateNull) - if err != nil { - return fmt.Errorf("failed to set pipeline state to null: %w", err) + if ret := pipeline.SetState(gst.StateNull); ret == gst.StateChangeFailure { + return fmt.Errorf("failed to set pipeline state to null") } return g.Wait() @@ -170,31 +168,35 @@ func MPEGTSVideoMP4AudioToMP4(ctx context.Context, videoInput io.Reader, audioIn "audiodemux.audio_0 ! parsebin ! queue name=audioqueue max-size-time=0 max-size-buffers=0 max-size-bytes=0", }, " ") - pipeline, err := gst.NewPipelineFromString(pipelineStr) + pipelineElem, err := gst.ParseLaunch(pipelineStr) if err != nil { return err } + pipeline, ok := pipelineElem.(gst.Pipeline) + if !ok { + return fmt.Errorf("failed to create pipeline: not a pipeline") + } - mux, err := pipeline.GetElementByName("mux") - if err != nil { - return err + mux := pipeline.GetByName("mux") + if mux == nil { + return fmt.Errorf("failed to get mux element") } - muxVideoSinkPad := mux.GetRequestPad("video_%u") + muxVideoSinkPad := mux.RequestPadSimple("video_%u") if muxVideoSinkPad == nil { return fmt.Errorf("failed to get video sink pad") } - muxAudioSinkPad := mux.GetRequestPad("audio_%u") + muxAudioSinkPad := mux.RequestPadSimple("audio_%u") if muxAudioSinkPad == nil { return fmt.Errorf("failed to get audio sink pad") } - videoQueue, err := pipeline.GetElementByName("videoqueue") - if err != nil { - return err + videoQueue := pipeline.GetByName("videoqueue") + if videoQueue == nil { + return fmt.Errorf("failed to get video queue element") } - audioQueue, err := pipeline.GetElementByName("audioqueue") - if err != nil { - return err + audioQueue := pipeline.GetByName("audioqueue") + if audioQueue == nil { + return fmt.Errorf("failed to get audio queue element") } videoQueueSrcPad := videoQueue.GetStaticPad("src") @@ -206,22 +208,22 @@ func MPEGTSVideoMP4AudioToMP4(ctx context.Context, videoInput io.Reader, audioIn return fmt.Errorf("failed to get audio queue source pad") } - ok := videoQueueSrcPad.Link(muxVideoSinkPad) - if ok != gst.PadLinkOK { - return fmt.Errorf("failed to link video queue source pad to mux video sink pad: %v", ok) + linked := videoQueueSrcPad.Link(muxVideoSinkPad) + if linked != gst.PadLinkOK { + return fmt.Errorf("failed to link video queue source pad to mux video sink pad: %v", linked) } - ok = audioQueueSrcPad.Link(muxAudioSinkPad) - if ok != gst.PadLinkOK { - return fmt.Errorf("failed to link audio queue source pad to mux audio sink pad: %v", ok) + linked = audioQueueSrcPad.Link(muxAudioSinkPad) + if linked != gst.PadLinkOK { + return fmt.Errorf("failed to link audio queue source pad to mux audio sink pad: %v", linked) } - videodemux, err := pipeline.GetElementByName("videodemux") - if err != nil { - return err + videodemux := pipeline.GetByName("videodemux") + if videodemux == nil { + return fmt.Errorf("failed to get video demux element") } - videoparse, err := pipeline.GetElementByName("videoparse") - if err != nil { - return err + videoparse := pipeline.GetByName("videoparse") + if videoparse == nil { + return fmt.Errorf("failed to get video parse element") } videoParseSinkPad := videoparse.GetStaticPad("sink") if videoParseSinkPad == nil { @@ -229,40 +231,36 @@ func MPEGTSVideoMP4AudioToMP4(ctx context.Context, videoInput io.Reader, audioIn } // Get elements - videoappsrc, err := pipeline.GetElementByName("videoappsrc") - if err != nil { - return err + videoappsrc := pipeline.GetByName("videoappsrc") + if videoappsrc == nil { + return fmt.Errorf("failed to get video appsrc element") } - audioappsrc, err := pipeline.GetElementByName("audioappsrc") - if err != nil { - return err + audioappsrc := pipeline.GetByName("audioappsrc") + if audioappsrc == nil { + return fmt.Errorf("failed to get audio appsrc element") } - appsink, err := pipeline.GetElementByName("appsink") - if err != nil { - return err + appsink := pipeline.GetByName("appsink") + if appsink == nil { + return fmt.Errorf("failed to get appsink element") } - videoSource := app.SrcFromElement(videoappsrc) - audioSource := app.SrcFromElement(audioappsrc) - sink := app.SinkFromElement(appsink) - // Set up source callbacks - videoSource.SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: ReaderNeedDataIncremental(ctx, videoInput), - EnoughDataFunc: func(self *app.Source) { + videoappsrc.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: ReaderNeedDataIncremental(ctx, videoInput), + EnoughData: func(self app.AppSrc) { // Nothing to do here }, - SeekDataFunc: func(self *app.Source, offset uint64) bool { + SeekData: func(self app.AppSrc, offset uint64) bool { return false // We don't support seeking }, }) - audioSource.SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: ReaderNeedDataIncremental(ctx, audioInput), - EnoughDataFunc: func(self *app.Source) { + audioappsrc.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: ReaderNeedDataIncremental(ctx, audioInput), + EnoughData: func(self app.AppSrc) { // Nothing to do here }, - SeekDataFunc: func(self *app.Source, offset uint64) bool { + SeekData: func(self app.AppSrc, offset uint64) bool { return false // We don't support seeking }, }) @@ -270,8 +268,8 @@ func MPEGTSVideoMP4AudioToMP4(ctx context.Context, videoInput io.Reader, audioIn wroteAnything := false // Set up sink callbacks - sink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { + appsink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { return gst.FlowOK @@ -279,10 +277,14 @@ func MPEGTSVideoMP4AudioToMP4(ctx context.Context, videoInput io.Reader, audioIn // Retrieve the buffer from the sample. buffer := sample.GetBuffer() - bs := buffer.Map(gst.MapRead).Bytes() - defer buffer.Unmap() + info, ok := buffer.Map(gst.MapRead) + if !ok { + log.Error(ctx, "failed to map buffer") + return gst.FlowError + } + defer info.Unmap() - _, err := output.Write(bs) + _, err := output.Write(info.Data()) if err != nil { log.Error(ctx, "error writing to output", "error", err) @@ -293,7 +295,7 @@ func MPEGTSVideoMP4AudioToMP4(ctx context.Context, videoInput io.Reader, audioIn return gst.FlowOK }, - NewPrerollFunc: func(self *app.Sink) gst.FlowReturn { + NewPreroll: func(self app.AppSink) gst.FlowReturn { return gst.FlowOK }, }) @@ -301,8 +303,8 @@ func MPEGTSVideoMP4AudioToMP4(ctx context.Context, videoInput io.Reader, audioIn ctx, cancel := context.WithCancel(ctx) defer cancel() - onPadAdded := func(element *gst.Element, pad *gst.Pad) { - if pad.GetDirection() == gst.PadDirectionSource { + onPadAdded := func(element gst.Element, pad gst.Pad) { + if pad.GetDirection() == gst.PadSrc { ok := pad.Link(videoParseSinkPad) if ok != gst.PadLinkOK { log.Error(ctx, "failed to link video parse sink pad to video demux pad", "error", ok) @@ -310,9 +312,7 @@ func MPEGTSVideoMP4AudioToMP4(ctx context.Context, videoInput io.Reader, audioIn } } } - if _, err := videodemux.Connect("pad-added", onPadAdded); err != nil { - return fmt.Errorf("failed connect pad-added handler: %w", err) - } + videodemux.ConnectPadAdded(onPadAdded) errCh := make(chan error) go func() { @@ -322,15 +322,13 @@ func MPEGTSVideoMP4AudioToMP4(ctx context.Context, videoInput io.Reader, audioIn }() // Start the pipeline - err = pipeline.SetState(gst.StatePlaying) - if err != nil { - return fmt.Errorf("failed to set pipeline state to playing: %w", err) + if ret := pipeline.SetState(gst.StatePlaying); ret == gst.StateChangeFailure { + return fmt.Errorf("failed to set pipeline state to playing") } defer func() { - err = pipeline.SetState(gst.StateNull) - if err != nil { - log.Error(ctx, "failed to set pipeline state to null", "error", err) + if ret := pipeline.SetState(gst.StateNull); ret == gst.StateChangeFailure { + log.Error(ctx, "failed to set pipeline state to null") } videoParseSinkPad = nil }() diff --git a/pkg/media/segmenter.go b/pkg/media/segmenter.go index 9269efb2b..a8b2fedb4 100644 --- a/pkg/media/segmenter.go +++ b/pkg/media/segmenter.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -27,24 +27,24 @@ import ( var FatalSegmentationErrors = false // element that takes the input stream, muxes to mp4, and signs the result -func SegmentElem(ctx context.Context, cli *config.CLI, streamer string, doH264Parse bool, cb func(ctx context.Context, buf []byte, now int64) error) (*gst.Element, error) { +func SegmentElem(ctx context.Context, cli *config.CLI, streamer string, doH264Parse bool, cb func(ctx context.Context, buf []byte, now int64) error) (gst.Element, error) { // elem, err := gst.NewElement("splitmuxsink name=splitter async-finalize=true sink-factory=appsink muxer-factory=matroskamux max-size-bytes=1") - elem, err := gst.NewElementWithProperties("splitmuxsink", map[string]any{ + elem := gst.ElementFactoryMakeWithProperties("splitmuxsink", map[string]any{ "name": "signer", "async-finalize": true, "sink-factory": "appsink", "muxer-factory": "mp4mux", - "max-size-bytes": 1, + "max-size-bytes": uint64(1), }) - if err != nil { - return nil, err + if elem == nil { + return nil, fmt.Errorf("failed to create splitmuxsink element") } - p := elem.GetRequestPad("video") + p := elem.RequestPadSimple("video") if p == nil { return nil, fmt.Errorf("failed to get video pad") } - p = elem.GetRequestPad("audio_%u") + p = elem.RequestPadSimple("audio_%u") if p == nil { return nil, fmt.Errorf("failed to get audio pad") } @@ -60,7 +60,7 @@ func SegmentElem(ctx context.Context, cli *config.CLI, streamer string, doH264Pa continue case <-time.After(time.Second * 30): log.Warn(ctx, "no new segment for 30 seconds") - elem.ErrorMessage(gst.DomainCore, gst.CoreErrorFailed, "No new segment for 30 seconds", "No new segment for 30 seconds (debug)") + elem.MessageError(gst.CoreErrorQuark(), int32(gst.CoreErrorFailed), "No new segment for 30 seconds", "No new segment for 30 seconds (debug)") return } } @@ -69,56 +69,32 @@ func SegmentElem(ctx context.Context, cli *config.CLI, streamer string, doH264Pa // we didn't need faststart but i'm leaving this commented here in case // you want to change any other muxer properties in the future - _, err = elem.Connect("muxer-added", func(split, muxEle *gst.Element) { - err := muxEle.SetProperty("presentation-time", false) - if err != nil { - panic("error setting presentation-time to false: " + err.Error()) - } - err = muxEle.SetProperty("interleave-bytes", InterleaveBytes) - if err != nil { - panic("error setting interleave-bytes" + err.Error()) - } - err = muxEle.SetProperty("interleave-time", InterleaveTime) - if err != nil { - panic("error setting interleave-time" + err.Error()) - } - err = muxEle.SetProperty("faststart", true) - if err != nil { - panic("error setting faststart" + err.Error()) - } - err = muxEle.SetProperty("movie-timescale", uint(60000)) - if err != nil { - panic("error setting movie-timescale" + err.Error()) - } - err = muxEle.SetProperty("trak-timescale", uint(60000)) - if err != nil { - panic("error setting trak-timescale" + err.Error()) - } + elem.Connect("muxer-added", func(_ gst.Element, muxEle gst.Element) { + muxEle.SetObjectProperty("presentation-time", false) + muxEle.SetObjectProperty("interleave-bytes", InterleaveBytes) + muxEle.SetObjectProperty("interleave-time", InterleaveTime) + muxEle.SetObjectProperty("faststart", true) + muxEle.SetObjectProperty("movie-timescale", uint32(60000)) + muxEle.SetObjectProperty("trak-timescale", uint32(60000)) }) - if err != nil { - return nil, fmt.Errorf("failed to connect muxer-added handler: %w", err) - } // channel to make sure data is emitted in order var ch chan struct{} - _, err = elem.Connect("sink-added", func(split, sinkEle *gst.Element) { + elem.Connect("sink-added", func(_ gst.Element, sinkEle gst.Element) { previousSegCh := ch mySegCh := make(chan struct{}, 1) ch = mySegCh buf := &bytes.Buffer{} - err := sinkEle.SetProperty("sync", false) - if err != nil { - panic("error setting sync to false: " + err.Error()) - } - appsink := app.SinkFromElement(sinkEle) - if appsink == nil { + sinkEle.SetObjectProperty("sync", false) + appsink, ok := sinkEle.(app.AppSink) + if !ok { panic("appsink should not be nil") } - appsink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, buf), - EOSFunc: func(sink *app.Sink) { + appsink.SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, buf), + EOS: func(sink app.AppSink) { // ctx, span := otel.Tracer("signer").Start(ctx, "SegmentAndSignElem", trace.WithAttributes( // attribute.String("streamer", ms.Streamer()), // )) @@ -132,7 +108,7 @@ func SegmentElem(ctx context.Context, cli *config.CLI, streamer string, doH264Pa resetTimer <- struct{}{} convergeAndSign := func() error { log.Debug(ctx, "signing segment", "size", len(bs)) - err = cb(ctx, bs, now) + err := cb(ctx, bs, now) if err != nil { return fmt.Errorf("error signing segment: %w", err) } @@ -150,7 +126,7 @@ func SegmentElem(ctx context.Context, cli *config.CLI, streamer string, doH264Pa return err case <-time.After(time.Second * 3): go func() { - err = cli.DataFileWrite([]string{"debug-recordings", streamer, fmt.Sprintf("converge-timeout-%d.mp4", now)}, bytes.NewReader(bs), true) + err := cli.DataFileWrite([]string{"debug-recordings", streamer, fmt.Sprintf("converge-timeout-%d.mp4", now)}, bytes.NewReader(bs), true) if err != nil { log.Error(ctx, "error writing debug recording", "error", err) } @@ -162,21 +138,18 @@ func SegmentElem(ctx context.Context, cli *config.CLI, streamer string, doH264Pa if err != nil { log.Error(ctx, "error in segmenter", "error", err) if FatalSegmentationErrors { - sink.ErrorMessage(gst.DomainCore, gst.CoreErrorFailed, "error in segmenter", err.Error()) + sink.MessageError(gst.CoreErrorQuark(), int32(gst.CoreErrorFailed), "error in segmenter", err.Error()) return } } }, }) }) - if err != nil { - return nil, fmt.Errorf("failed to connect sink-added handler: %w", err) - } return elem, nil } -func (mm *MediaManager) SegmentAndSignElem(ctx context.Context, ms MediaSigner) (*gst.Element, error) { +func (mm *MediaManager) SegmentAndSignElem(ctx context.Context, ms MediaSigner) (gst.Element, error) { tracer := otel.Tracer("signer") streamer := ms.Streamer() @@ -234,22 +207,25 @@ func SegmentUnsigned(ctx context.Context, cli *config.CLI, streamer string, inpu "demux. ! queue ! h264parse name=videoparse disable-passthrough=true config-interval=0", "demux. ! queue ! opusparse name=audioparse", } - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) + launched, err := gst.ParseLaunch(strings.Join(pipelineSlice, "\n")) if err != nil { return fmt.Errorf("error creating SegmentUnsigned pipeline: %w", err) } + pipeline, ok := launched.(gst.Pipeline) + if !ok { + return fmt.Errorf("error creating SegmentUnsigned pipeline: parsed description is not a pipeline") + } - srcele, err := pipeline.GetElementByName("appsrc") - if err != nil { - return err + srcele := pipeline.GetByName("appsrc") + if srcele == nil { + return fmt.Errorf("appsrc element not found") } - src := app.SrcFromElement(srcele) - src.SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: ReaderNeedDataIncremental(ctx, input), + srcele.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: ReaderNeedDataIncremental(ctx, input), }) - videoParseEle, err := pipeline.GetElementByName("videoparse") - if err != nil { - return err + videoParseEle := pipeline.GetByName("videoparse") + if videoParseEle == nil { + return fmt.Errorf("videoparse element not found") } segmenter, err := SegmentElem(ctx, cli, streamer, doH264Parse, func(ctx context.Context, buf []byte, now int64) error { @@ -263,21 +239,18 @@ func SegmentUnsigned(ctx context.Context, cli *config.CLI, streamer string, inpu return err } - err = pipeline.Add(segmenter) - if err != nil { - return err + if !pipeline.Add(segmenter) { + return fmt.Errorf("failed to add segmenter to pipeline") } - err = videoParseEle.Link(segmenter) - if err != nil { - return err + if !videoParseEle.Link(segmenter) { + return fmt.Errorf("failed to link videoparse to segmenter") } - audioparse, err := pipeline.GetElementByName("audioparse") - if err != nil { - return err + audioparse := pipeline.GetByName("audioparse") + if audioparse == nil { + return fmt.Errorf("audioparse element not found") } - err = audioparse.Link(segmenter) - if err != nil { - return err + if !audioparse.Link(segmenter) { + return fmt.Errorf("failed to link audioparse to segmenter") } busErr := make(chan error) @@ -287,15 +260,13 @@ func SegmentUnsigned(ctx context.Context, cli *config.CLI, streamer string, inpu busErr <- err }() - err = pipeline.SetState(gst.StatePlaying) - if err != nil { - return err + if pipeline.SetState(gst.StatePlaying) == gst.StateChangeFailure { + return fmt.Errorf("error setting SegmentUnsigned pipeline to playing") } defer func() { - err := pipeline.SetState(gst.StateNull) - if err != nil { - log.Error(ctx, "error setting pipeline to null state", "error", err) + if pipeline.SetState(gst.StateNull) == gst.StateChangeFailure { + log.Error(ctx, "error setting pipeline to null state") } }() diff --git a/pkg/media/thumbnail.go b/pkg/media/thumbnail.go index 7b2d0a0c6..a7b6cb349 100644 --- a/pkg/media/thumbnail.go +++ b/pkg/media/thumbnail.go @@ -9,8 +9,8 @@ import ( "sync" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "stream.place/streamplace/pkg/bus" "stream.place/streamplace/pkg/log" ) @@ -68,7 +68,7 @@ func thumbnailFromMP4(ctx context.Context, flat []byte, w io.Writer, format stri encoder = "jpegenc" } - pipeline, err := gst.NewPipelineFromString(strings.Join([]string{ + launched, err := gst.ParseLaunch(strings.Join([]string{ "appsrc name=src ! decodebin ! videoconvert ! videoscale ! videorate ! capsfilter caps=video/x-raw,width=[1,1280],height=[1,720],pixel-aspect-ratio=1/1,framerate=1/999999 ! ", encoder, " ! appsink sync=false name=appsink", @@ -76,23 +76,27 @@ func thumbnailFromMP4(ctx context.Context, flat []byte, w io.Writer, format stri if err != nil { return fmt.Errorf("create thumbnail pipeline: %w", err) } + pipeline, ok := launched.(gst.Pipeline) + if !ok { + return fmt.Errorf("create thumbnail pipeline: parsed description is not a pipeline") + } defer func() { - if e := pipeline.SetState(gst.StateNull); e != nil { - log.Error(ctx, "thumbnail: failed to set pipeline to null", "error", e) + if pipeline.SetState(gst.StateNull) == gst.StateChangeFailure { + log.Error(ctx, "thumbnail: failed to set pipeline to null") } }() - srcEle, err := pipeline.GetElementByName("src") - if err != nil { - return fmt.Errorf("thumbnail: get appsrc: %w", err) + srcEle := pipeline.GetByName("src") + if srcEle == nil { + return fmt.Errorf("thumbnail: get appsrc: element not found") } - app.SrcFromElement(srcEle).SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: ReaderNeedDataIncremental(ctx, bytes.NewReader(flat)), + srcEle.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: ReaderNeedDataIncremental(ctx, bytes.NewReader(flat)), }) - appsink, err := pipeline.GetElementByName("appsink") - if err != nil { - return fmt.Errorf("thumbnail: get appsink: %w", err) + appsink := pipeline.GetByName("appsink") + if appsink == nil { + return fmt.Errorf("thumbnail: get appsink: element not found") } errCh := make(chan error, 1) @@ -100,16 +104,19 @@ func thumbnailFromMP4(ctx context.Context, flat []byte, w io.Writer, format stri thumbCh := make(chan struct{}) var once sync.Once - app.SinkFromElement(appsink).SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { + appsink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { return gst.FlowOK } - buffer := sample.GetBuffer() - bs := buffer.Map(gst.MapRead).Bytes() - defer buffer.Unmap() - if _, err := w.Write(bs); err != nil { + info, ok := sample.GetBuffer().Map(gst.MapRead) + if !ok { + log.Error(ctx, "thumbnail: failed to map buffer") + return gst.FlowError + } + defer info.Unmap() + if _, err := w.Write(info.Data()); err != nil { log.Error(ctx, "thumbnail: error writing output", "error", err) return gst.FlowError } @@ -118,14 +125,14 @@ func thumbnailFromMP4(ctx context.Context, flat []byte, w io.Writer, format stri }, }) - if err := pipeline.SetState(gst.StatePlaying); err != nil { - return fmt.Errorf("thumbnail: set playing: %w", err) + if pipeline.SetState(gst.StatePlaying) == gst.StateChangeFailure { + return fmt.Errorf("thumbnail: set playing: state change failed") } select { case <-thumbCh: // Got the frame; wind the pipeline down so HandleBusMessages returns. - pipeline.Error(ErrPipelineDone.Error(), ErrPipelineDone) + pipeline.PostMessage(gst.NewMessageError(pipeline, ErrPipelineDone.Error(), ErrPipelineDone)) <-errCh return nil case err := <-errCh: @@ -169,19 +176,22 @@ func Thumbnail(ctx context.Context, r io.Reader, w io.Writer, format string) err "fakesink name=audiofakesink sync=false", } - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) + launched, err := gst.ParseLaunch(strings.Join(pipelineSlice, "\n")) if err != nil { return fmt.Errorf("error creating Thumbnail pipeline: %w", err) } + pipeline, ok := launched.(gst.Pipeline) + if !ok { + return fmt.Errorf("error creating Thumbnail pipeline: parsed description is not a pipeline") + } demuxBin, err := ConcatDemuxBin(ctx, seg, false) if err != nil { return fmt.Errorf("failed to create demux bin: %w", err) } - err = pipeline.Add(demuxBin.Element) - if err != nil { - return fmt.Errorf("failed to add demux bin to pipeline: %w", err) + if !pipeline.Add(demuxBin) { + return fmt.Errorf("failed to add demux bin to pipeline") } demuxBinPadVideoSrc := demuxBin.GetStaticPad("video_0") @@ -194,14 +204,14 @@ func Thumbnail(ctx context.Context, r io.Reader, w io.Writer, format string) err return fmt.Errorf("failed to get demux bin audio src pad") } - decode, err := pipeline.GetElementByName("decode") - if err != nil { - return fmt.Errorf("failed to get decodebin element: %w", err) + decode := pipeline.GetByName("decode") + if decode == nil { + return fmt.Errorf("failed to get decodebin element") } - audioFakeSink, err := pipeline.GetElementByName("audiofakesink") - if err != nil { - return fmt.Errorf("failed to get audio fakesink element: %w", err) + audioFakeSink := pipeline.GetByName("audiofakesink") + if audioFakeSink == nil { + return fmt.Errorf("failed to get audio fakesink element") } linked := demuxBinPadVideoSrc.Link(decode.GetStaticPad("sink")) @@ -215,19 +225,17 @@ func Thumbnail(ctx context.Context, r io.Reader, w io.Writer, format string) err } defer func() { - err := pipeline.SetState(gst.StateNull) - if err != nil { - log.Error(ctx, "failed to set pipeline state to null", "error", err) + if pipeline.SetState(gst.StateNull) == gst.StateChangeFailure { + log.Error(ctx, "failed to set pipeline state to null") } - err = pipeline.Remove(demuxBin.Element) - if err != nil { - log.Error(ctx, "failed to remove demux bin from pipeline", "error", err) + if !pipeline.Remove(demuxBin) { + log.Error(ctx, "failed to remove demux bin from pipeline") } }() - appsink, err := pipeline.GetElementByName("appsink") - if err != nil { - return err + appsink := pipeline.GetByName("appsink") + if appsink == nil { + return fmt.Errorf("failed to get appsink element") } errCh := make(chan error) @@ -239,19 +247,22 @@ func Thumbnail(ctx context.Context, r io.Reader, w io.Writer, format string) err thumbCh := make(chan struct{}) - sink := app.SinkFromElement(appsink) - sink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { + appsink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { return gst.FlowOK } // Retrieve the buffer from the sample. - buffer := sample.GetBuffer() - bs := buffer.Map(gst.MapRead).Bytes() - defer buffer.Unmap() + info, ok := sample.GetBuffer().Map(gst.MapRead) + if !ok { + log.Error(ctx, "failed to map buffer") + return gst.FlowError + } + defer info.Unmap() + bs := info.Data() _, err := w.Write(bs) log.Debug(ctx, "wrote buffer", "length", len(bs)) @@ -266,13 +277,13 @@ func Thumbnail(ctx context.Context, r io.Reader, w io.Writer, format string) err }, }) - if err := pipeline.SetState(gst.StatePlaying); err != nil { - return fmt.Errorf("error setting pipeline state: %w", err) + if pipeline.SetState(gst.StatePlaying) == gst.StateChangeFailure { + return fmt.Errorf("error setting pipeline state to playing") } <-thumbCh // signals the pipeline to clean up cleanly - pipeline.Error(ErrPipelineDone.Error(), ErrPipelineDone) + pipeline.PostMessage(gst.NewMessageError(pipeline, ErrPipelineDone.Error(), ErrPipelineDone)) busErr := <-errCh diff --git a/pkg/media/transcode.go b/pkg/media/transcode.go index 69969fcd6..1914dd66f 100644 --- a/pkg/media/transcode.go +++ b/pkg/media/transcode.go @@ -8,7 +8,7 @@ import ( "strconv" "strings" - "github.com/go-gst/go-gst/gst" + "github.com/go-gst/go-gst/pkg/gst" "stream.place/streamplace/pkg/atproto" "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/crypto/signers" @@ -120,7 +120,7 @@ func (mm *MediaManager) audioCompletionTarget(ctx context.Context, seg []byte) ( // callers keep the original signed video and use only the transcoded audio // track. Callers wire the `src`/`sink` callbacks and set the pipeline playing. // target is the codec being produced: "opus" (source AAC) or "aac" (source Opus). -func buildAudioTranscodePipeline(target string) (*gst.Pipeline, error) { +func buildAudioTranscodePipeline(target string) (gst.Pipeline, error) { // Queue sizing: a single qtdemux feeds both branches, so if either queue // hits a limit and blocks the demux, the sibling branch starves and mp4mux // deadlocks waiting for it (then appsrc backpressures and the Feed blocks). @@ -140,7 +140,7 @@ func buildAudioTranscodePipeline(target string) (*gst.Pipeline, error) { return nil, fmt.Errorf("unsupported transcode target %q", target) } - pipeline, err := gst.NewPipelineFromString(strings.Join([]string{ + launched, err := gst.ParseLaunch(strings.Join([]string{ "appsrc name=src ! qtdemux name=demux", constants.Queue2Big + " name=vq ! h264parse name=vparse", audioChain, @@ -148,50 +148,52 @@ func buildAudioTranscodePipeline(target string) (*gst.Pipeline, error) { if err != nil { return nil, fmt.Errorf("create transcode pipeline: %w", err) } + pipeline, ok := launched.(gst.Pipeline) + if !ok { + return nil, fmt.Errorf("create transcode pipeline: parsed description is not a pipeline") + } // Fragmented muxer + appsink, built programmatically so we can set the // enum fragment-mode and pre-request pads in video-then-audio order. - mux, err := gst.NewElementWithProperties("mp4mux", map[string]any{ + mux := gst.ElementFactoryMakeWithProperties("mp4mux", map[string]any{ "name": "mux", - "fragment-mode": 0, - "fragment-duration": 1, + "fragment-mode": int32(0), + "fragment-duration": uint32(1), }) - if err != nil { - return nil, fmt.Errorf("create mp4mux: %w", err) + if mux == nil { + return nil, fmt.Errorf("create mp4mux: element factory returned nil") } - sink, err := gst.NewElementWithProperties("appsink", map[string]any{"name": "sink", "sync": false}) - if err != nil { - return nil, fmt.Errorf("create appsink: %w", err) + sink := gst.ElementFactoryMakeWithProperties("appsink", map[string]any{"name": "sink", "sync": false}) + if sink == nil { + return nil, fmt.Errorf("create appsink: element factory returned nil") } - if err := pipeline.AddMany(mux, sink); err != nil { - return nil, fmt.Errorf("add mux/sink: %w", err) + if !pipeline.AddMany(mux, sink) { + return nil, fmt.Errorf("add mux/sink to pipeline failed") } - if err := mux.Link(sink); err != nil { - return nil, fmt.Errorf("link mux→sink: %w", err) + if !mux.Link(sink) { + return nil, fmt.Errorf("link mux→sink failed") } // Pre-request pads in order so the muxer assigns video=track 1, audio=2. - videoMuxPad := mux.GetRequestPad("video_%u") - audioMuxPad := mux.GetRequestPad("audio_%u") + videoMuxPad := mux.RequestPadSimple("video_%u") + audioMuxPad := mux.RequestPadSimple("audio_%u") if videoMuxPad == nil || audioMuxPad == nil { return nil, fmt.Errorf("failed to request mp4mux pads") } - vparse, err := pipeline.GetElementByName("vparse") - if err == nil { - // The passed-through video is muxl's cut clock for the transcoded - // audio: it must cut exactly where the source segments were cut, - // at IDRs only (see installIDRKeyframeProbe), or the audio chunks - // and the source segments pair up out of step and the completed - // track drifts from the video. - installIDRKeyframeProbe(context.Background(), vparse.GetStaticPad("src"), "completion") - } - if err != nil { - return nil, err - } - aenc, err := pipeline.GetElementByName("aenc") - if err != nil { - return nil, err + vparse := pipeline.GetByName("vparse") + if vparse == nil { + return nil, fmt.Errorf("vparse element not found") + } + // The passed-through video is muxl's cut clock for the transcoded + // audio: it must cut exactly where the source segments were cut, + // at IDRs only (see installIDRKeyframeProbe), or the audio chunks + // and the source segments pair up out of step and the completed + // track drifts from the video. + installIDRKeyframeProbe(context.Background(), vparse.GetStaticPad("src"), "completion") + aenc := pipeline.GetByName("aenc") + if aenc == nil { + return nil, fmt.Errorf("aenc element not found") } // Repair the passthrough video's timestamps before the muxer. Variable-frame- // rate WHIP capture can emit several frames at one instant (observed: three @@ -212,21 +214,21 @@ func buildAudioTranscodePipeline(target string) (*gst.Pipeline, error) { var lastTS gst.ClockTime haveLast := false vparseSrc := vparse.GetStaticPad("src") - vparseSrc.AddProbe(gst.PadProbeTypeBuffer, func(_ *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn { + vparseSrc.AddProbe(gst.PadProbeTypeBuffer, func(_ gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn { buf := info.GetBuffer() if buf == nil { return gst.PadProbeOK } - ts := buf.PresentationTimestamp() + ts := buf.PTS() if ts == gst.ClockTimeNone { - ts = buf.DecodingTimestamp() + ts = buf.DTS() } if haveLast && (ts == gst.ClockTimeNone || ts <= lastTS) { ts = lastTS + tsStep // missing or non-monotonic → carry forward } else if ts == gst.ClockTimeNone { ts = 0 // very first buffer with no timestamp at all } - buf.SetPresentationTimestamp(ts) + buf.SetPTS(ts) if d := buf.Duration(); d == gst.ClockTimeNone || d == 0 { buf.SetDuration(tsStep) } @@ -241,21 +243,21 @@ func buildAudioTranscodePipeline(target string) (*gst.Pipeline, error) { return nil, fmt.Errorf("link audio chain → mux: %v", r) } - vq, err := pipeline.GetElementByName("vq") - if err != nil { - return nil, err + vq := pipeline.GetByName("vq") + if vq == nil { + return nil, fmt.Errorf("vq element not found") } - aq, err := pipeline.GetElementByName("aq") - if err != nil { - return nil, err + aq := pipeline.GetByName("aq") + if aq == nil { + return nil, fmt.Errorf("aq element not found") } - demux, err := pipeline.GetElementByName("demux") - if err != nil { - return nil, err + demux := pipeline.GetByName("demux") + if demux == nil { + return nil, fmt.Errorf("demux element not found") } - if _, err := demux.Connect("pad-added", func(self *gst.Element, pad *gst.Pad) { + demux.ConnectPadAdded(func(_ gst.Element, pad gst.Pad) { name := pad.GetName() - var dst *gst.Pad + var dst gst.Pad switch { case strings.HasPrefix(name, "video"): dst = vq.GetStaticPad("sink") @@ -268,9 +270,7 @@ func buildAudioTranscodePipeline(target string) (*gst.Pipeline, error) { // non-fatal: a stray pad (e.g. a second audio track) is just dropped fmt.Printf("transcode: failed to link demux pad %s: %v\n", name, r) } - }); err != nil { - return nil, fmt.Errorf("connect demux pad-added: %w", err) - } + }) return pipeline, nil } diff --git a/pkg/media/transcode_stream.go b/pkg/media/transcode_stream.go index 79ce2d6b5..69476bffb 100644 --- a/pkg/media/transcode_stream.go +++ b/pkg/media/transcode_stream.go @@ -10,8 +10,8 @@ import ( "sync" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/muxl" ) @@ -317,27 +317,27 @@ func (t *streamTranscoder) run(feedR *io.PipeReader) error { return err } defer func() { - if e := pipeline.SetState(gst.StateNull); e != nil { - log.Error(ctx, "stream transcode: set null", "error", e) + if pipeline.SetState(gst.StateNull) == gst.StateChangeFailure { + log.Error(ctx, "stream transcode: set null failed") } }() - srcEle, err := pipeline.GetElementByName("src") - if err != nil { - return err + srcEle := pipeline.GetByName("src") + if srcEle == nil { + return fmt.Errorf("stream transcode: src element not found") } - app.SrcFromElement(srcEle).SetCallbacks(&app.SourceCallbacks{ - NeedDataFunc: ReaderNeedDataIncremental(ctx, feedR), + srcEle.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{ + NeedData: ReaderNeedDataIncremental(ctx, feedR), }) // appsink output → io.Pipe → muxl streaming segmenter. muxR, muxW := io.Pipe() - sink, err := pipeline.GetElementByName("sink") - if err != nil { - return err + sink := pipeline.GetByName("sink") + if sink == nil { + return fmt.Errorf("stream transcode: sink element not found") } - app.SinkFromElement(sink).SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, muxW), + sink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, muxW), }) eventCh := make(chan *muxl.MuxlEvent, 16) @@ -354,8 +354,8 @@ func (t *streamTranscoder) run(feedR *io.PipeReader) error { busErr <- err }() - if err := pipeline.SetState(gst.StatePlaying); err != nil { - return fmt.Errorf("stream transcode: set playing: %w", err) + if pipeline.SetState(gst.StatePlaying) == gst.StateChangeFailure { + return fmt.Errorf("stream transcode: set playing: state change failed") } // Consume per-GoP transcoded segments, pairing each 1:1 (in order) with the diff --git a/pkg/media/vod_pipeline.go b/pkg/media/vod_pipeline.go index d746ae845..c73fe3dc6 100644 --- a/pkg/media/vod_pipeline.go +++ b/pkg/media/vod_pipeline.go @@ -5,14 +5,13 @@ import ( "errors" "fmt" "io" - "strconv" "strings" "sync" "sync/atomic" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -171,14 +170,10 @@ func RunVODPipeline(ctx context.Context, src io.ReaderAt, size int64, out io.Wri // can emit progress and notice a stall. prog := newProgressWriter(out) - pipeline, err := gst.NewPipeline("vod-process") - if err != nil { - span.RecordError(err) - return result, fmt.Errorf("new pipeline: %w", err) - } + pipeline := gst.NewPipeline("vod-process").(gst.Pipeline) defer func() { - if setErr := pipeline.BlockSetState(gst.StateNull); setErr != nil { - log.Error(ctx, "failed to set pipeline to null", "error", setErr) + if pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone) == gst.StateChangeFailure { + log.Error(ctx, "failed to set pipeline to null") } }() @@ -186,22 +181,22 @@ func RunVODPipeline(ctx context.Context, src io.ReaderAt, size int64, out io.Wri if err != nil { return result, fmt.Errorf("source bin: %w", err) } - if err := pipeline.Add(srcBin.Element); err != nil { - return result, fmt.Errorf("add src bin: %w", err) + if !pipeline.Add(srcBin) { + return result, fmt.Errorf("add src bin to pipeline failed") } - parsebin, err := gst.NewElementWithProperties("parsebin", map[string]any{ + parsebin := gst.ElementFactoryMakeWithProperties("parsebin", map[string]any{ "name": "vod-parsebin", "expose-all-streams": true, }) - if err != nil { - return result, fmt.Errorf("create parsebin: %w", err) + if parsebin == nil { + return result, fmt.Errorf("create parsebin: element factory returned nil") } - if err := pipeline.Add(parsebin); err != nil { - return result, fmt.Errorf("add parsebin: %w", err) + if !pipeline.Add(parsebin) { + return result, fmt.Errorf("add parsebin to pipeline failed") } - if err := srcBin.Link(parsebin); err != nil { - return result, fmt.Errorf("link src bin -> parsebin: %w", err) + if !srcBin.Link(parsebin) { + return result, fmt.Errorf("link src bin -> parsebin failed") } // Tell parsebin's autoplug machinery to stop at the elementary-stream @@ -216,51 +211,47 @@ func RunVODPipeline(ctx context.Context, src io.ReaderAt, size int64, out io.Wri // caps notify plugs aacparse-2, and so on until the cgo stack // overflows and the process crashes. Stopping autoplug before any // parser is added keeps that loop from ever starting. - autoplugID, err := parsebin.Connect("autoplug-continue", func(_ *gst.Element, _ *gst.Pad, caps *gst.Caps) bool { + autoplugID := parsebin.Connect("autoplug-continue", func(_ gst.Element, _ gst.Pad, caps *gst.Caps) bool { if caps == nil { return true } - s := caps.GetStructureAt(0) + s := caps.GetStructure(0) if s == nil { return true } - return !elementaryStreamCaps[s.Name()] + return !elementaryStreamCaps[s.GetName()] }) - if err != nil { - return result, fmt.Errorf("connect autoplug-continue: %w", err) - } defer parsebin.HandlerDisconnect(autoplugID) - mp4mux, err := gst.NewElementWithProperties("mp4mux", map[string]any{ + mp4mux := gst.ElementFactoryMakeWithProperties("mp4mux", map[string]any{ "name": "vod-mp4mux", - "fragment-mode": 0, - "fragment-duration": 1, + "fragment-mode": int32(0), + "fragment-duration": uint32(1), }) - if err != nil { - return result, fmt.Errorf("create mp4mux: %w", err) + if mp4mux == nil { + return result, fmt.Errorf("create mp4mux: element factory returned nil") } - if err := pipeline.Add(mp4mux); err != nil { - return result, fmt.Errorf("add mp4mux: %w", err) + if !pipeline.Add(mp4mux) { + return result, fmt.Errorf("add mp4mux to pipeline failed") } - appsink, err := gst.NewElementWithProperties("appsink", map[string]any{ + appsink := gst.ElementFactoryMakeWithProperties("appsink", map[string]any{ "name": "vod-appsink", "sync": false, "async": false, }) - if err != nil { - return result, fmt.Errorf("create appsink: %w", err) + if appsink == nil { + return result, fmt.Errorf("create appsink: element factory returned nil") } - if err := pipeline.Add(appsink); err != nil { - return result, fmt.Errorf("add appsink: %w", err) + if !pipeline.Add(appsink) { + return result, fmt.Errorf("add appsink to pipeline failed") } - if err := mp4mux.Link(appsink); err != nil { - return result, fmt.Errorf("link mp4mux -> appsink: %w", err) + if !mp4mux.Link(appsink) { + return result, fmt.Errorf("link mp4mux -> appsink failed") } - sink := app.SinkFromElement(appsink) - sink.SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: WriterNewSample(ctx, prog), + appsink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: WriterNewSample(ctx, prog), }) // padErr captures the first wiring error from pad-added so we can @@ -278,7 +269,7 @@ func RunVODPipeline(ctx context.Context, src io.ReaderAt, size int64, out io.Wri audioCaps string ) - handlerID, err := parsebin.Connect("pad-added", func(_ *gst.Element, pad *gst.Pad) { + handlerID := parsebin.ConnectPadAdded(func(_ gst.Element, pad gst.Pad) { mu.Lock() defer mu.Unlock() if padErr != nil { @@ -289,12 +280,12 @@ func RunVODPipeline(ctx context.Context, src io.ReaderAt, size int64, out io.Wri log.Warn(ctx, "parsebin pad missing caps; ignoring", "pad", pad.GetName()) return } - s := caps.GetStructureAt(0) + s := caps.GetStructure(0) if s == nil { log.Warn(ctx, "parsebin caps missing structure; ignoring", "pad", pad.GetName()) return } - capsName := s.Name() + capsName := s.GetName() padCtx := log.WithLogValues(ctx, "pad", pad.GetName(), "caps", capsName) log.Debug(padCtx, "parsebin pad-added") switch { @@ -305,7 +296,7 @@ func RunVODPipeline(ctx context.Context, src io.ReaderAt, size int64, out io.Wri } if err := wireVideoChain(padCtx, pipeline, mp4mux, pad, s); err != nil { padErr = err - pipeline.Error("video chain failed", err) + pipeline.PostMessage(gst.NewMessageError(pipeline, "video chain failed", err)) return } gotVideo = true @@ -326,7 +317,7 @@ func RunVODPipeline(ctx context.Context, src io.ReaderAt, size int64, out io.Wri transcoded, err := wireAudioChain(padCtx, pipeline, mp4mux, pad, s) if err != nil { padErr = err - pipeline.Error("audio chain failed", err) + pipeline.PostMessage(gst.NewMessageError(pipeline, "audio chain failed", err)) return } gotAudio = true @@ -347,9 +338,6 @@ func RunVODPipeline(ctx context.Context, src io.ReaderAt, size int64, out io.Wri log.Warn(padCtx, "non-A/V parsebin pad; ignoring") } }) - if err != nil { - return result, fmt.Errorf("connect pad-added: %w", err) - } // Disconnecting before pipeline tear-down breaks the closure->pipeline // reference cycle that would otherwise pin every element alive past // the function's return. @@ -358,9 +346,10 @@ func RunVODPipeline(ctx context.Context, src io.ReaderAt, size int64, out io.Wri busErr := make(chan error, 1) go func() { busErr <- HandleBusMessages(ctx, pipeline) }() - if err := pipeline.SetState(gst.StatePlaying); err != nil { + if pipeline.SetState(gst.StatePlaying) == gst.StateChangeFailure { + err := fmt.Errorf("set playing: state change failed") span.RecordError(err) - return result, fmt.Errorf("set playing: %w", err) + return result, err } log.Debug(ctx, "pipeline transitioned to PLAYING") @@ -426,7 +415,7 @@ func RunVODPipeline(ctx context.Context, src io.ReaderAt, size int64, out io.Wri // Query the pipeline's duration before tearing it down. gstreamer // reports it in nanoseconds (FormatTime); we expose milliseconds // because that's what place.stream.video's duration field takes. - if ok, durNS := pipeline.QueryDuration(gst.FormatTime); ok { + if durNS, ok := pipeline.QueryDuration(gst.FormatTime); ok { result.DurationMS = durNS / int64(time.Millisecond) span.SetAttributes(attribute.Int64("duration_ms", result.DurationMS)) log.Debug(ctx, "queried pipeline duration", "duration_ms", result.DurationMS) @@ -457,24 +446,17 @@ func RunVODPipeline(ctx context.Context, src io.ReaderAt, size int64, out io.Wri } // extractVideoTrack pulls codec/dimensions/framerate out of a parsebin -// video pad's caps Structure. The framerate is a GstFraction that -// go-gst doesn't expose typed access for, so we stringify and parse -// num/den ourselves — same workaround the existing media_data_parser -// uses for live-segment probing. +// video pad's caps Structure. func extractVideoTrack(capsName string, s *gst.Structure) *VODVideoTrack { t := &VODVideoTrack{Codec: strings.TrimPrefix(capsName, "video/")} - if v, _ := s.GetValue("width"); v != nil { - if w, ok := v.(int); ok { - t.Width = w - } + if w, ok := s.GetInt("width"); ok { + t.Width = int(w) } - if v, _ := s.GetValue("height"); v != nil { - if h, ok := v.(int); ok { - t.Height = h - } + if h, ok := s.GetInt("height"); ok { + t.Height = int(h) } - if v, _ := s.GetValue("framerate"); v != nil { - t.FPSNum, t.FPSDen = parseFraction(v) + if num, den, ok := s.GetFraction("framerate"); ok { + t.FPSNum, t.FPSDen = int(num), int(den) } return t } @@ -487,20 +469,14 @@ func extractVideoTrack(capsName string, s *gst.Structure) *VODVideoTrack { // what came in. func extractAudioTrack(capsName string, s *gst.Structure, transcoded bool) *VODAudioTrack { t := &VODAudioTrack{Codec: strings.TrimPrefix(capsName, "audio/")} - if v, _ := s.GetValue("rate"); v != nil { - if r, ok := v.(int); ok { - t.Rate = r - } + if r, ok := s.GetInt("rate"); ok { + t.Rate = int(r) } - if v, _ := s.GetValue("channels"); v != nil { - if c, ok := v.(int); ok { - t.Channels = c - } + if c, ok := s.GetInt("channels"); ok { + t.Channels = int(c) } - if v, _ := s.GetValue("mpegversion"); v != nil { - if mv, ok := v.(int); ok { - t.MPEGVersion = mv - } + if mv, ok := s.GetInt("mpegversion"); ok { + t.MPEGVersion = int(mv) } if transcoded { // The chain re-encoded to AAC; the final fMP4 carries AAC @@ -511,36 +487,19 @@ func extractAudioTrack(capsName string, s *gst.Structure, transcoded bool) *VODA return t } -// parseFraction takes a GstFraction-shaped value from a caps Structure -// (go-gst returns it as a stringer with "num/den" format) and returns -// the numerator and denominator. Returns 0,0 on any parse failure. -func parseFraction(v any) (num, den int) { - str := fmt.Sprintf("%v", v) - parts := strings.SplitN(str, "/", 2) - if len(parts) != 2 { - return 0, 0 - } - n, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) - d, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) - if err1 != nil || err2 != nil { - return 0, 0 - } - return n, d -} - type elemSpec struct { name string props map[string]any } -func wireVideoChain(ctx context.Context, pipeline *gst.Pipeline, mp4mux *gst.Element, pad *gst.Pad, caps *gst.Structure) error { - codec := caps.Name() +func wireVideoChain(ctx context.Context, pipeline gst.Pipeline, mp4mux gst.Element, pad gst.Pad, caps *gst.Structure) error { + codec := caps.GetName() if codec != "video/x-h264" { return fmt.Errorf("%w: video codec %q (only h264 is supported)", ErrUnsupportedCodec, codec) } chain, err := buildChain(pipeline, "vod-video", []elemSpec{ {name: "queue"}, - {name: "h264parse", props: map[string]any{"config-interval": 0, "disable-passthrough": true}}, + {name: "h264parse", props: map[string]any{"config-interval": int32(0), "disable-passthrough": true}}, }) if err != nil { return err @@ -548,7 +507,7 @@ func wireVideoChain(ctx context.Context, pipeline *gst.Pipeline, mp4mux *gst.Ele if err := linkChain(chain); err != nil { return err } - sinkPad := mp4mux.GetRequestPad("video_%u") + sinkPad := mp4mux.RequestPadSimple("video_%u") if sinkPad == nil { return fmt.Errorf("mp4mux: no video request pad") } @@ -565,8 +524,8 @@ func wireVideoChain(ctx context.Context, pipeline *gst.Pipeline, mp4mux *gst.Ele // wireAudioChain returns whether the chain inserted a transcode step // (i.e., the input wasn't already AAC). -func wireAudioChain(ctx context.Context, pipeline *gst.Pipeline, mp4mux *gst.Element, pad *gst.Pad, caps *gst.Structure) (transcoded bool, err error) { - codec := caps.Name() +func wireAudioChain(ctx context.Context, pipeline gst.Pipeline, mp4mux gst.Element, pad gst.Pad, caps *gst.Structure) (transcoded bool, err error) { + codec := caps.GetName() specs, transcoded, err := audioChainSpec(codec, caps) if err != nil { return false, err @@ -578,7 +537,7 @@ func wireAudioChain(ctx context.Context, pipeline *gst.Pipeline, mp4mux *gst.Ele if err := linkChain(chain); err != nil { return false, err } - sinkPad := mp4mux.GetRequestPad("audio_%u") + sinkPad := mp4mux.RequestPadSimple("audio_%u") if sinkPad == nil { return false, fmt.Errorf("mp4mux: no audio request pad") } @@ -593,7 +552,7 @@ func wireAudioChain(ctx context.Context, pipeline *gst.Pipeline, mp4mux *gst.Ele return transcoded, nil } -func chainNames(elems []*gst.Element) []string { +func chainNames(elems []gst.Element) []string { names := make([]string, len(elems)) for i, e := range elems { names[i] = e.GetName() @@ -623,8 +582,7 @@ func audioChainSpec(codec string, caps *gst.Structure) (specs []elemSpec, transc out := []elemSpec{{name: "queue"}} switch codec { case "audio/mpeg": - v, _ := caps.GetValue("mpegversion") - ver, _ := v.(int) + ver, _ := caps.GetInt("mpegversion") if ver != 2 && ver != 4 { return nil, false, fmt.Errorf("%w: audio/mpeg mpegversion=%d", ErrUnsupportedCodec, ver) } @@ -640,36 +598,36 @@ func audioChainSpec(codec string, caps *gst.Structure) (specs []elemSpec, transc } } -func buildChain(pipeline *gst.Pipeline, prefix string, specs []elemSpec) ([]*gst.Element, error) { - out := make([]*gst.Element, len(specs)) +func buildChain(pipeline gst.Pipeline, prefix string, specs []elemSpec) ([]gst.Element, error) { + out := make([]gst.Element, len(specs)) for i, spec := range specs { props := map[string]any{} for k, v := range spec.props { props[k] = v } props["name"] = fmt.Sprintf("%s-%s-%d", prefix, spec.name, i) - e, err := gst.NewElementWithProperties(spec.name, props) - if err != nil { - return nil, fmt.Errorf("create %s: %w", spec.name, err) + e := gst.ElementFactoryMakeWithProperties(spec.name, props) + if e == nil { + return nil, fmt.Errorf("create %s: element factory returned nil", spec.name) } - if err := pipeline.Add(e); err != nil { - return nil, fmt.Errorf("add %s: %w", spec.name, err) + if !pipeline.Add(e) { + return nil, fmt.Errorf("add %s to pipeline failed", spec.name) } out[i] = e } return out, nil } -func linkChain(elems []*gst.Element) error { +func linkChain(elems []gst.Element) error { for i := 0; i < len(elems)-1; i++ { - if err := elems[i].Link(elems[i+1]); err != nil { - return fmt.Errorf("link %s -> %s: %w", elems[i].GetName(), elems[i+1].GetName(), err) + if !elems[i].Link(elems[i+1]) { + return fmt.Errorf("link %s -> %s failed", elems[i].GetName(), elems[i+1].GetName()) } } return nil } -func syncAll(elems []*gst.Element) { +func syncAll(elems []gst.Element) { for _, e := range elems { e.SyncStateWithParent() } @@ -680,7 +638,7 @@ func syncAll(elems []*gst.Element) { // // 1. GetCurrentCaps — fast path, works once a CAPS event has fixed // the pad. For many inputs this is set at pad-added time. -// 2. GetStream().Caps — parsebin attaches a GstStream to each +// 2. GetStream().GetCaps — parsebin attaches a GstStream to each // exposed pad with the codec caps from its internal parser, // even before the downstream caps query has fixated. Works // reliably for parsebin and decodebin3. @@ -690,12 +648,12 @@ func syncAll(elems []*gst.Element) { // // Returns nil if all three fall short, in which case the caller // should log+ignore the pad — there's no productive way to wire it. -func padCaps(pad *gst.Pad) *gst.Caps { +func padCaps(pad gst.Pad) *gst.Caps { if c := pad.GetCurrentCaps(); c != nil { return c } if stream := pad.GetStream(); stream != nil { - if c := stream.Caps(); c != nil { + if c := stream.GetCaps(); c != nil { return c } } diff --git a/pkg/media/webrtc_ingest.go b/pkg/media/webrtc_ingest.go index 995fcd7ac..7796b7b36 100644 --- a/pkg/media/webrtc_ingest.go +++ b/pkg/media/webrtc_ingest.go @@ -6,8 +6,8 @@ import ( "strings" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/google/uuid" "github.com/pion/rtcp" "github.com/pion/webrtc/v4" @@ -40,7 +40,7 @@ func (mm *MediaManager) WebRTCIngest(ctx context.Context, offer *webrtc.SessionD // worker passes a muxlSignSegmentElem wired to its frame socket and a nil // keyRevSigner. The cancellable ctx and signerElem are built by the caller (the // signer element's goroutines are tied to ctx). -func (mm *MediaManager) webRTCIngestPipeline(ctx context.Context, cancel context.CancelFunc, offer *webrtc.SessionDescription, peerConnection rtcrec.PeerConnection, signerElem *gst.Element, keyRevSigner MediaSigner, done chan error) (*webrtc.SessionDescription, error) { +func (mm *MediaManager) webRTCIngestPipeline(ctx context.Context, cancel context.CancelFunc, offer *webrtc.SessionDescription, peerConnection rtcrec.PeerConnection, signerElem gst.Element, keyRevSigner MediaSigner, done chan error) (*webrtc.SessionDescription, error) { // Allow us to receive 1 audio track, and 1 video track if _, err := peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio); err != nil { return nil, fmt.Errorf("failed to add audio transceiver: %w", err) @@ -54,49 +54,46 @@ func (mm *MediaManager) webRTCIngestPipeline(ctx context.Context, cancel context "appsrc format=time do-timestamp=true name=audiosrc ! capsfilter caps=application/x-rtp,media=audio,encoding-name=OPUS,payload=111 ! rtpopusdepay ! opusparse ! queue.sink_1", } - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) + pipelineElem, err := gst.ParseLaunch(strings.Join(pipelineSlice, "\n")) if err != nil { return nil, fmt.Errorf("failed to create GStreamer pipeline: %w", err) } + pipeline, ok := pipelineElem.(gst.Pipeline) + if !ok { + return nil, fmt.Errorf("failed to create GStreamer pipeline: not a pipeline") + } // Only an IDR starts a segment (see installIDRKeyframeProbe). - if parseEle, err := pipeline.GetElementByName("videoparse"); err == nil { + if parseEle := pipeline.GetByName("videoparse"); parseEle != nil { streamer := "whip" if keyRevSigner != nil { streamer = keyRevSigner.Streamer() } installIDRKeyframeProbe(ctx, parseEle.GetStaticPad("src"), streamer) } - queue, err := pipeline.GetElementByName("queue") - if err != nil { - return nil, fmt.Errorf("failed to get queue element from pipeline: %w", err) + queue := pipeline.GetByName("queue") + if queue == nil { + return nil, fmt.Errorf("failed to get queue element from pipeline") } - // err = queue.Link(signerElem) - // if err != nil { - // return nil, fmt.Errorf("failed to link queue to signer element: %w", err) - // } - videoSrcPads, err := queue.GetSrcPads() - if err != nil { - return nil, fmt.Errorf("failed to get videoSrcPads from queue: %w", err) - } - if len(videoSrcPads) != 2 { + // multiqueue pairs sink_N with src_N: video is sink_0, audio is sink_1. + videoSrcPad := queue.GetStaticPad("src_0") + audioSrcPad := queue.GetStaticPad("src_1") + if videoSrcPad == nil || audioSrcPad == nil { return nil, fmt.Errorf("failed to get videoSrcPads from queue") } - videoSrcPad := videoSrcPads[0] - audioSrcPad := videoSrcPads[1] - videoSrcElem, err := pipeline.GetElementByName("videosrc") - if err != nil { - return nil, fmt.Errorf("failed to get videoSrcElem element from pipeline: %w", err) + videoSrcElem := pipeline.GetByName("videosrc") + if videoSrcElem == nil { + return nil, fmt.Errorf("failed to get videoSrcElem element from pipeline") } - videoSrc := app.SrcFromElement(videoSrcElem) + videoSrc := videoSrcElem.(app.AppSrc) - audioSrcElem, err := pipeline.GetElementByName("audiosrc") - if err != nil { - return nil, fmt.Errorf("failed to get audioSrcElem element from pipeline: %w", err) + audioSrcElem := pipeline.GetByName("audiosrc") + if audioSrcElem == nil { + return nil, fmt.Errorf("failed to get audioSrcElem element from pipeline") } - audioSrc := app.SrcFromElement(audioSrcElem) + audioSrc := audioSrcElem.(app.AppSrc) // Set the remote SessionDescription if err = peerConnection.SetRemoteDescription(*offer); err != nil { @@ -118,22 +115,17 @@ func (mm *MediaManager) webRTCIngestPipeline(ctx context.Context, cancel context gatherComplete := rtcrec.GatheringCompletePromise(peerConnection) // cancel + signerElem are provided by the caller. - err = pipeline.Add(signerElem) - if err != nil { - cancel() - return nil, fmt.Errorf("failed to add signer element to pipeline: %w", err) - } - signerElemPads, err := signerElem.GetPads() - if err != nil { + if !pipeline.Add(signerElem) { cancel() - return nil, fmt.Errorf("failed to get signerElemPads from signer element: %w", err) + return nil, fmt.Errorf("failed to add signer element to pipeline") } - if len(signerElemPads) != 2 { + // The signer bin exposes its sink pads as video_0 and audio_0. + signerElemVideoPad := signerElem.GetStaticPad("video_0") + signerElemAudioPad := signerElem.GetStaticPad("audio_0") + if signerElemVideoPad == nil || signerElemAudioPad == nil { cancel() return nil, fmt.Errorf("failed to get signerElemPads from signer element") } - signerElemVideoPad := signerElemPads[0] - signerElemAudioPad := signerElemPads[1] linked := videoSrcPad.Link(signerElemVideoPad) if linked != gst.PadLinkOK { cancel() @@ -189,9 +181,8 @@ func (mm *MediaManager) webRTCIngestPipeline(ctx context.Context, cancel context log.Debug(ctx, "starting pipeline") // Start the pipeline - err = pipeline.SetState(gst.StatePlaying) - if err != nil { - log.Log(ctx, "failed to set pipeline state", "error", err) + if ret := pipeline.SetState(gst.StatePlaying); ret == gst.StateChangeFailure { + log.Log(ctx, "failed to set pipeline state") cancel() } @@ -248,7 +239,7 @@ func (mm *MediaManager) webRTCIngestPipeline(ctx context.Context, cancel context i, _, readErr := track.Read(buf) if readErr != nil { log.Log(ctx, "failed to read track", "error", readErr) - videoSrc.EndStream() + videoSrc.EndOfStream() return } // if ctx.Err() != nil { @@ -259,9 +250,12 @@ func (mm *MediaManager) webRTCIngestPipeline(ctx context.Context, cancel context log.Debug(ctx, "got video data", "len", len(buf[:i])) } - gbuf := gst.NewBufferWithSize(int64(len(buf[:i]))) - gbuf.Map(gst.MapWrite).WriteData(buf[:i]) - gbuf.Unmap() + gbuf, bufErr := newBufferFromBytes(buf[:i]) + if bufErr != nil { + log.Log(ctx, "failed to build buffer", "error", bufErr) + cancel() + return + } ret := videoSrc.PushBuffer(gbuf) if ret != gst.FlowOK { @@ -287,7 +281,7 @@ func (mm *MediaManager) webRTCIngestPipeline(ctx context.Context, cancel context i, _, readErr := track.Read(buf) if readErr != nil { log.Log(ctx, "failed to read track", "error", readErr) - audioSrc.EndStream() + audioSrc.EndOfStream() return } // if ctx.Err() != nil { @@ -298,9 +292,12 @@ func (mm *MediaManager) webRTCIngestPipeline(ctx context.Context, cancel context log.Debug(ctx, "got audio data", "len", len(buf[:i])) } - gbuf := gst.NewBufferWithSize(int64(len(buf[:i]))) - gbuf.Map(gst.MapWrite).WriteData(buf[:i]) - gbuf.Unmap() + gbuf, bufErr := newBufferFromBytes(buf[:i]) + if bufErr != nil { + log.Log(ctx, "failed to build buffer", "error", bufErr) + cancel() + return + } ret := audioSrc.PushBuffer(gbuf) if ret != gst.FlowOK { log.Log(ctx, "failed to push buffer", "error", ret) @@ -319,16 +316,16 @@ func (mm *MediaManager) webRTCIngestPipeline(ctx context.Context, cancel context <-ctx.Done() - if err := pipeline.BlockSetState(gst.StateNull); err != nil { - log.Log(ctx, "failed to set pipeline state to null", "error", err) + if ret := pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone); ret == gst.StateChangeFailure { + log.Log(ctx, "failed to set pipeline state to null") } - if err := audioSrcElem.SetState(gst.StateNull); err != nil { - log.Log(ctx, "failed to set audioSrcElem state to null", "error", err) + if ret := audioSrcElem.SetState(gst.StateNull); ret == gst.StateChangeFailure { + log.Log(ctx, "failed to set audioSrcElem state to null") } - if err := videoSrcElem.SetState(gst.StateNull); err != nil { - log.Log(ctx, "failed to set videoSrcElem state to null", "error", err) + if ret := videoSrcElem.SetState(gst.StateNull); ret == gst.StateChangeFailure { + log.Log(ctx, "failed to set videoSrcElem state to null") } log.Log(ctx, "webrtc ingest pipeline done") diff --git a/pkg/media/whip_worker_test.go b/pkg/media/whip_worker_test.go index 481e34968..f89e1c6c7 100644 --- a/pkg/media/whip_worker_test.go +++ b/pkg/media/whip_worker_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" pionmedia "github.com/pion/webrtc/v4/pkg/media" "github.com/pion/webrtc/v4" @@ -114,21 +114,23 @@ func produceWHIPMedia(t *testing.T, ctx context.Context, video, audio *webrtc.Tr "videotestsrc is-live=true ! video/x-raw,width=320,height=240,framerate=30/1 ! x264enc key-int-max=15 tune=zerolatency speed-preset=ultrafast ! h264parse ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=vsink", "audiotestsrc is-live=true ! audioconvert ! audioresample ! opusenc ! opusparse ! appsink name=asink", }, "\n") - pipeline, err := gst.NewPipelineFromString(desc) + pipelineElem, err := gst.ParseLaunch(desc) require.NoError(t, err) + pipeline := pipelineElem.(gst.Pipeline) pump := func(name string, track *webrtc.TrackLocalStaticSample, dur time.Duration) { - ele, gerr := pipeline.GetElementByName(name) - require.NoError(t, gerr) - app.SinkFromElement(ele).SetCallbacks(&app.SinkCallbacks{ - NewSampleFunc: func(sink *app.Sink) gst.FlowReturn { + ele := pipeline.GetByName(name) + require.NotNil(t, ele) + ele.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{ + NewSample: func(sink app.AppSink) gst.FlowReturn { sample := sink.PullSample() if sample == nil { return gst.FlowEOS } - buf := sample.GetBuffer() - data := buf.Map(gst.MapRead).Bytes() - buf.Unmap() + data, cerr := copyBufferData(sample.GetBuffer()) + if cerr != nil { + return gst.FlowError + } if werr := track.WriteSample(pionmedia.Sample{Data: data, Duration: dur}); werr != nil { return gst.FlowError } @@ -143,7 +145,7 @@ func produceWHIPMedia(t *testing.T, ctx context.Context, video, audio *webrtc.Tr <-ctx.Done() _ = pipeline.SetState(gst.StateNull) }() - require.NoError(t, pipeline.SetState(gst.StatePlaying)) + require.NotEqual(t, gst.StateChangeFailure, pipeline.SetState(gst.StatePlaying)) } // TestWHIPWorkerLoopback is the full WHIP media path: a pion client offers, diff --git a/pkg/spxrpc/com_atproto_moderation_test.go b/pkg/spxrpc/com_atproto_moderation_test.go index 586ece40b..75998d10e 100644 --- a/pkg/spxrpc/com_atproto_moderation_test.go +++ b/pkg/spxrpc/com_atproto_moderation_test.go @@ -22,8 +22,8 @@ import ( indigoatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/decred/dcrd/dcrec/secp256k1" - "github.com/go-gst/go-gst/gst" - "github.com/go-gst/go-gst/gst/app" + "github.com/go-gst/go-gst/pkg/gst" + app "github.com/go-gst/go-gst/pkg/gstapp" "github.com/lestrrat-go/jwx/v2/jwk" slogGorm "github.com/orandin/slog-gorm" "github.com/slok/go-http-metrics/middleware" @@ -152,19 +152,22 @@ func TestCreateReportRouting(t *testing.T) { mm, err = media.MakeMediaManager(ctx, cli, nil, nil, nil, nil, nil) require.NoError(t, err) // The MP4 ingest worker takes H264+AAC; the fixture has Opus audio. - pipeline, err := gst.NewPipelineFromString("appsrc name=source ! qtdemux name=d d. ! queue ! h264parse ! mp4mux name=mux fragment-duration=500 ! appsink name=sink d. ! queue ! opusdec ! audioconvert ! audioresample ! fdkaacenc ! aacparse ! mux.") - require.NoError(t, err) - defer func() { require.NoError(t, pipeline.SetState(gst.StateNull)) }() - source, err := pipeline.GetElementByName("source") - require.NoError(t, err) - app.SrcFromElement(source).SetCallbacks(&app.SourceCallbacks{NeedDataFunc: media.ReaderNeedDataIncremental(ctx, bytes.NewReader(fixture))}) - sink, err := pipeline.GetElementByName("sink") + element, err := gst.ParseLaunch("appsrc name=source ! qtdemux name=d d. ! queue ! h264parse ! mp4mux name=mux fragment-duration=500 ! appsink name=sink d. ! queue ! opusdec ! audioconvert ! audioresample ! fdkaacenc ! aacparse ! mux.") require.NoError(t, err) + pipeline := element.(gst.Pipeline) + defer func() { + require.Equal(t, gst.StateChangeSuccess, pipeline.BlockSetState(gst.StateNull, gst.ClockTimeNone)) + }() + source := pipeline.GetByName("source") + require.NotNil(t, source) + source.(app.AppSrc).SetCallbacks(&app.AppSrcCallbacks{NeedData: media.ReaderNeedDataIncremental(ctx, bytes.NewReader(fixture))}) + sink := pipeline.GetByName("sink") + require.NotNil(t, sink) var mp4 bytes.Buffer - app.SinkFromElement(sink).SetCallbacks(&app.SinkCallbacks{NewSampleFunc: media.WriterNewSample(ctx, &mp4)}) + sink.(app.AppSink).SetCallbacks(&app.AppSinkCallbacks{NewSample: media.WriterNewSample(ctx, &mp4)}) busErr := make(chan error, 1) go func() { busErr <- media.HandleBusMessages(ctx, pipeline) }() - require.NoError(t, pipeline.SetState(gst.StatePlaying)) + require.NotEqual(t, gst.StateChangeFailure, pipeline.SetState(gst.StatePlaying)) require.NoError(t, <-busErr) var frames bytes.Buffer require.NoError(t, media.RunMP4IngestWorker(ctx, cfg, bytes.NewReader(mp4.Bytes()), ingestframe.NewWriter(&frames), func() []byte { return manifest })) -- 2.54.0 From 91a6cb9ff5d22e3f78af41a5ac0a7589e344f43b Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Wed, 14 Oct 2026 12:02:38 -0500 Subject: [PATCH 29/76] restore inline chat display Signed-off-by: Natalie Bridgers --- js/components/src/components/chat/chat-message.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index 25e7996dc..242fb200c 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -217,7 +217,7 @@ const MessageBodyWeb = ({ item }: { item: ChatMessageViewHydrated }) => { Date: Wed, 14 Oct 2026 12:03:34 -0500 Subject: [PATCH 30/76] clean up old comments Signed-off-by: Natalie Bridgers --- .../src/components/chat/chat-message.tsx | 61 ++++--------------- 1 file changed, 11 insertions(+), 50 deletions(-) diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index 242fb200c..6ef1051c1 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -42,12 +42,9 @@ import { } from "./user-profile-card"; import { VerifiedBadge } from "./verified-badge"; -// Deterministic per-user color, muted so a busy chat doesn't turn into a -// rainbow, and clamped for contrast against the dark chat surface: colors -// that are too dark get mixed toward white until they clear a minimum -// luminance. Same input always yields the same output. -const MIN_LUMA = 110; // 0-255 relative luminance floor -const DESAT = 0.45; // how far to pull each color toward its own gray +// Desaturate profile colors and brighten dark ones for the chat background. +const MIN_LUMA = 110; +const DESAT = 0.45; const clampChannel = (v: number) => Math.max(0, Math.min(255, Math.round(v))); const getRgbColor = (color?: { red: number; @@ -61,13 +58,12 @@ const getRgbColor = (color?: { green += (luma - green) * DESAT; blue += (luma - blue) * DESAT; if (luma < MIN_LUMA) { - // mix toward white just enough to reach the floor const t = (MIN_LUMA - luma) / (255 - luma || 1); red = red + (255 - red) * t; green = green + (255 - green) * t; blue = blue + (255 - blue) * t; } - return `rgb(${clampChannel(red)}, ${clampChannel(green)}, ${clampChannel(blue)})`; // eslint-disable-line streamplace/no-token-literals -- dynamic user color / soft shadow + return `rgb(${clampChannel(red)}, ${clampChannel(green)}, ${clampChannel(blue)})`; // eslint-disable-line streamplace/no-token-literals -- dynamic profile color }; const LinkSegment = ({ @@ -165,11 +161,7 @@ export const RichTextMessage = ({ return segs.map((seg, i) => renderSegment(seg, i, userCache, profileUrl)); }; -// Web flows the whole message inline inside a single , with the badges and -// handle rendered as an inline-block via display: "inline". -// Chat is dense but legible: 14px (size base), handles in medium weight. -// Branding key chatNameColors=off: names in the default text color instead -// of each user's chosen chat color. +// chatNameColors=off uses the default text color instead of the profile color. function useNameColor(): ( color?: Parameters[0], ) => string | undefined { @@ -201,21 +193,12 @@ const MessageBodyWeb = ({ item }: { item: ChatMessageViewHydrated }) => { const dids = useMemo(() => [item.author.did], [item.author.did]); const profile = useAvatars(dids)[item.author.did]; return ( - // One inline flow: the handle, colon and message are children of a single - // so words fill the space after the handle and wrap naturally, the - // way an unselectable row did. selectableText opts the flow back into web - // text selection. - // - // A handle wider than the row used to stretch the row past the chat - // column, whose scroll container hides horizontal overflow, putting the - // colon and the message out of reach. The handle clamps to one line and - // ellipsizes instead: minWidth 0 lets the group (react-native-web's View - // default is flexShrink 0) and this shrink below their min-content - // width, and the trigger box around the handle is capped at 100% in - // UserProfileCard. + // Keep the handle and message in one text flow so the message wraps after + // the handle. Allow the row to shrink when a handle exceeds its width. { {formatHandleWithAt(item.author)} - {/* The colon rides in the handle's box so the pair stays one unit on - one line instead of the colon dropping to the next line. */} {":"} @@ -258,9 +239,7 @@ const MessageBodyWeb = ({ item }: { item: ChatMessageViewHydrated }) => { ); }; -// Native can't reliably nest views or images inside , so the badges sit in -// a flex row beside the message instead of inline. Tapping the badges or the -// handle opens the same profile bottom sheet via two triggers on one menu. +// Native keeps badges beside the text because views cannot be nested in Text. const MessageBodyNative = ({ item }: { item: ChatMessageViewHydrated }) => { const nameColor = useNameColor(); const badges = useVisibleBadges(item.badges); @@ -331,16 +310,10 @@ export function relativeAge(dateString: string): string { return `${Math.round(h / 24)}d`; } -// The avatar layout (branding chatLayout=avatar): a post-style row with the -// author's avatar, display name, handle and age on one line and the text -// beneath, the way a social timeline shows a reply. Badges sit after the -// name; the profile card opens from the name as it does in compact rows. const AvatarMessageBody = ({ item }: { item: ChatMessageViewHydrated }) => { const { theme } = useTheme(); const badges = useVisibleBadges(item.badges); const author = item.author; - // Hydrated messages carry handle and display name but not the avatar; - // the profile cache batches getProfiles across visible rows. const dids = useMemo(() => [author.did], [author.did]); const profile = useAvatars(dids)[author.did]; const avatar = profile?.avatar || author.avatar; @@ -349,17 +322,12 @@ const AvatarMessageBody = ({ item }: { item: ChatMessageViewHydrated }) => { const name = displayName || handle; const nameColor = useNameColor()(item.chatProfile?.color); const meta = { fontSize: 15, lineHeight: 23, color: theme.colors.text2 }; - // The name line is a flex row rather than nested so the badges (which - // are views) sit after the name on every platform, a long handle truncates - // instead of pushing the age off the edge, and native never nests views in - // text. - // Room the verified mark and badges take beside the name (see below). + // Keep badges outside Text so they render on native as well as web. const verified = author.verification?.verifiedStatus === "valid" || profile?.verification?.verifiedStatus === "valid"; const marksWidth = (verified ? 20 : 0) + (badges?.length ? 6 + badges.length * 22 : 0); - // text. return ( { { alignItems: "center", minWidth: 0, maxWidth: "100%" }, ]} > - {/* Name, marks and handle share one box beside the time. The - handle is the only thing in it that shrinks, so it gives way - first; the name can't shrink at all on web and is instead - capped at the box minus the marks, so it only truncates once - the handle is gone. (Native's layout engine weights shrink by - the factor, so there the 1000x handle does the same job.) */} + {/* Reserve room for marks when truncating the name on web. */} { {handle} ) : null} - {/* The time follows the handle (left-aligned with the rest), - and never shrinks. */} {"· " + relativeAge(item.record.createdAt)} -- 2.54.0 From b076c5eb17cda1fe483bce0cd5156b7a8fea22c8 Mon Sep 17 00:00:00 2001 From: iameli Date: Tue, 13 Oct 2026 18:11:56 -0700 Subject: [PATCH 31/76] app: mac build fixes --- Makefile | 21 ++++++++------- js/app/app.config.ts | 26 +++++++++++++++++++ .../streamplace-dev-setup.md | 18 +++++++++++++ 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 773786633..6c81bb4f0 100644 --- a/Makefile +++ b/Makefile @@ -145,19 +145,22 @@ android-debug: .build/bundletool.jar android-keystore .PHONY: ios ios: app - xcodebuild \ - -workspace ./js/app/ios/Streamplace.xcworkspace \ + bash -eu -c ' \ + set -- ./js/app/ios/*.xcworkspace; \ + workspace="$$1"; \ + scheme="$${workspace##*/}"; \ + scheme="$${scheme%.xcworkspace}"; \ + xcodebuild \ + -workspace "$$workspace" \ -sdk iphoneos \ + -destination "generic/platform=iOS" \ -configuration Release \ - -scheme Streamplace \ + -scheme "$$scheme" \ -archivePath ./bin/streamplace-$(VERSION)-ios-release.xcarchive \ - CODE_SIGN_IDENTITY=- \ - AD_HOC_CODE_SIGNING_ALLOWED=YES \ - CODE_SIGN_STYLE=Automatic \ - DEVELOPMENT_TEAM=ZZZZZZZZZZ \ - clean archive | xcpretty \ + CODE_SIGNING_ALLOWED=NO \ + clean archive \ && cd bin \ - && tar -czvf streamplace-$(VERSION)-ios-release.xcarchive.tar.gz streamplace-$(VERSION)-ios-release.xcarchive + && tar -czvf streamplace-$(VERSION)-ios-release.xcarchive.tar.gz streamplace-$(VERSION)-ios-release.xcarchive' # _____ ____ # / ____|/ __ \ diff --git a/js/app/app.config.ts b/js/app/app.config.ts index 139c68fa5..9a41c3c82 100644 --- a/js/app/app.config.ts +++ b/js/app/app.config.ts @@ -3,6 +3,7 @@ import { withAndroidManifest, withDangerousMod, withEntitlementsPlist, + withPodfile, withXcodeProject, } from "expo/config-plugins"; import fs from "fs"; @@ -24,6 +25,30 @@ export const withoutNotificationsIOS: ConfigPlugin = (config) => { return config; }; +// React Native normalizes library targets, but not their privacy/resource bundles. +// Xcode 27 rejects those bundles' older deployment targets. +const withMinimumIOSDeploymentTarget: ConfigPlugin = (config) => + withPodfile(config, (config) => { + const hook = "post_install do |installer|"; + if (!config.modResults.contents.includes(hook)) { + throw new Error("Missing CocoaPods post_install hook"); + } + config.modResults.contents = config.modResults.contents.replace( + hook, + `${hook} + minimum = Gem::Version.new(min_ios_version_supported) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |build_config| + current = build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + if current && Gem::Version.new(current) < minimum + build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = minimum.to_s + end + end + end`, + ); + return config; + }); + const withAndroidProfileable = (config) => { return withAndroidManifest(config, (config) => { const androidManifest = config.modResults.manifest; @@ -297,6 +322,7 @@ export default function () { }, }, ], + withMinimumIOSDeploymentTarget, [ "expo-asset", { diff --git a/js/docs/src/content/docs/guides/start-contributing/streamplace-dev-setup.md b/js/docs/src/content/docs/guides/start-contributing/streamplace-dev-setup.md index f9495847e..1dd02c41d 100644 --- a/js/docs/src/content/docs/guides/start-contributing/streamplace-dev-setup.md +++ b/js/docs/src/content/docs/guides/start-contributing/streamplace-dev-setup.md @@ -109,6 +109,24 @@ pnpm run start #### iOS Build +iOS builds require full Xcode with the iOS SDK installed and CocoaPods on your +`PATH`. Select the intended Xcode installation with `xcode-select`. + +For a Release device archive, run `make ios`. It rebuilds the frontend and native +projects, selects the generated workspace and matching scheme (`Devplace` for +development, the configured brand name for production), and writes an unsigned +`.xcarchive` and `.xcarchive.tar.gz` under `bin/`. Set +`SP_PRODUCTION_RELEASE=true` for the production app. Signing/exporting the archive +for distribution is a separate step; installing on a physical device requires +your Apple development team and provisioning profile. + +Prebuild also raises pod deployment targets below React Native's minimum, +including privacy/resource bundles rejected by Xcode 27. Higher pod minimums are +preserved. Regenerate with `pnpm app prebuild` after changing native configuration; +do not edit generated Xcode projects or the Podfile. + +For a simulator or development-device build: + ```shell pnpm run app ios ``` -- 2.54.0 From b93002fe1353d62f5a56985a89d80d5ab5763f70 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 14 Oct 2026 13:29:42 -0700 Subject: [PATCH 32/76] chat: use first baseline alignment --- js/components/src/components/chat/chat-message.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index 6ef1051c1..211ad5386 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -198,9 +198,9 @@ const MessageBodyWeb = ({ item }: { item: ChatMessageViewHydrated }) => { Date: Wed, 14 Oct 2026 13:41:21 -0700 Subject: [PATCH 33/76] Revert "player: keep the web video alive across layout changes" --- js/app/components/provider/provider.tsx | 6 +- .../mobile-player/fullscreen.native.tsx | 2 - .../components/mobile-player/fullscreen.tsx | 24 +--- .../mobile-player/playback-session.tsx | 50 ------- .../src/components/mobile-player/player.tsx | 52 ++++++- .../components/mobile-player/video-host.tsx | 129 ------------------ js/components/tests/portrait-player.test.tsx | 3 - js/e2e-web/flows/stream.spec.ts | 81 +---------- 8 files changed, 55 insertions(+), 292 deletions(-) delete mode 100644 js/components/src/components/mobile-player/playback-session.tsx delete mode 100644 js/components/src/components/mobile-player/video-host.tsx diff --git a/js/app/components/provider/provider.tsx b/js/app/components/provider/provider.tsx index 211c8f690..2c8a59972 100644 --- a/js/app/components/provider/provider.tsx +++ b/js/app/components/provider/provider.tsx @@ -1,5 +1,4 @@ import { LinkingOptions } from "@react-navigation/native"; -import { VideoHostProvider } from "@streamplace/components/src/components/mobile-player/video-host"; import React, { useEffect } from "react"; import { SafeAreaProvider } from "react-native-safe-area-context"; import SharedProvider from "./provider.shared"; @@ -21,10 +20,7 @@ export default function Provider({ }, []); return ( - - {/* Owns players' + {children} ); } diff --git a/js/components/src/components/mobile-player/fullscreen.native.tsx b/js/components/src/components/mobile-player/fullscreen.native.tsx index a584cd3ee..78cbbd56b 100644 --- a/js/components/src/components/mobile-player/fullscreen.native.tsx +++ b/js/components/src/components/mobile-player/fullscreen.native.tsx @@ -22,7 +22,6 @@ import { VideoRetry, } from "../.."; import { colors, surfaces } from "../../lib/theme/tokens"; -import { usePlaybackSession } from "./playback-session"; import { AudioOnlyOverlay } from "./ui/audio-only-overlay"; import Video from "./video.native"; @@ -32,7 +31,6 @@ export function Fullscreen(props: { objectFit?: "contain" | "cover"; pictureInPictureEnabled?: boolean; }) { - usePlaybackSession(props.src); const ref = useRef(null); const insets = useSafeAreaInsets(); const dimensions = useWindowDimensions(); diff --git a/js/components/src/components/mobile-player/fullscreen.tsx b/js/components/src/components/mobile-player/fullscreen.tsx index b877ef0de..a977720ce 100644 --- a/js/components/src/components/mobile-player/fullscreen.tsx +++ b/js/components/src/components/mobile-player/fullscreen.tsx @@ -11,10 +11,8 @@ import { usePlayerStore, } from "../.."; import { View } from "../../components/ui"; -import { usePlaybackSession } from "./playback-session"; import { AudioOnlyOverlay } from "./ui/audio-only-overlay"; import Video from "./video"; -import { HostedVideo } from "./video-host"; import VideoRetry from "./video-retry"; export function Fullscreen(props: { @@ -125,15 +123,12 @@ export function Fullscreen(props: { ref={divRef} style={{ width: "100%", height: "100%", overflow: "hidden" }} > - - - - - + + ); } - -// Hosted with the video, so a layout change that remounts this Fullscreen -// doesn't drop the session and change the video's URL. -function PlaybackSession(props: { src: string }) { - usePlaybackSession(props.src); - return null; -} diff --git a/js/components/src/components/mobile-player/playback-session.tsx b/js/components/src/components/mobile-player/playback-session.tsx deleted file mode 100644 index f78542e3f..000000000 --- a/js/components/src/components/mobile-player/playback-session.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect } from "react"; -import { place } from "streamplace"; -import { useLivestreamStoreOptional } from "../../livestream-store/use-store"; -import { usePlayerStore } from "../../player-store"; -import { useDID } from "../../streamplace-store"; -import { usePDSAgent } from "../../streamplace-store/xrpc"; - -// Pre-live over HLS: the streamer previewing their own stream needs their -// own playback session (an anonymous one opens published streams only). -// It only opens the caller's own stream, so ask when this is that viewer, -// and again before it expires. Fullscreen calls this next to the video so -// every place that mounts a player (the live dashboard included) gets it, -// and so the session lasts as long as the video whose URL carries it. -export function usePlaybackSession(src: string) { - const myDid = useDID(); - const streamerDid = useLivestreamStoreOptional((x) => x.profile?.did); - const agent = usePDSAgent(); - const setPlaybackSession = usePlayerStore((x) => x.setPlaybackSession); - const isMine = - !!myDid && (src === myDid || (!!streamerDid && streamerDid === myDid)); - useEffect(() => { - if (!isMine || !agent) { - setPlaybackSession(undefined); - return; - } - let cancelled = false; - let timer: ReturnType | null = null; - const fetchToken = async () => { - try { - const res = await agent.client.call( - place.stream.playback.getPlaybackSession, - ); - if (cancelled) return; - setPlaybackSession(res.sid); - const msLeft = new Date(res.expiresAt).getTime() - Date.now(); - timer = setTimeout(fetchToken, Math.max(60_000, msLeft - 5 * 60_000)); - } catch (e) { - if (cancelled) return; - console.warn("could not fetch a playback session", e); - timer = setTimeout(fetchToken, 60_000); - } - }; - void fetchToken(); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - setPlaybackSession(undefined); - }; - }, [isMine, agent, setPlaybackSession]); -} diff --git a/js/components/src/components/mobile-player/player.tsx b/js/components/src/components/mobile-player/player.tsx index f740924cc..4cf49ddfd 100644 --- a/js/components/src/components/mobile-player/player.tsx +++ b/js/components/src/components/mobile-player/player.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { place } from "streamplace"; import { flex, h, layout, w, zIndex } from "../../lib/theme/atoms"; import { useLivestreamStoreOptional } from "../../livestream-store/use-store"; import { @@ -7,10 +8,12 @@ import { usePlayerStore, } from "../../player-store"; import { + useDID, useMuted, useSetMuted, useStreamplaceStore, } from "../../streamplace-store"; +import { usePDSAgent } from "../../streamplace-store/xrpc"; import { Text, View } from "../ui"; import { Fullscreen } from "./fullscreen"; import { PlayerProps } from "./props"; @@ -56,6 +59,49 @@ export function Player( } }, [streamHasBFrames, streamForcesHLS, protocol, setStreamForcesHLS]); + // Pre-live over HLS: the streamer previewing their own stream needs their + // own playback session (an anonymous one opens published streams only). + // It only opens the caller's own stream, so ask when this is that viewer, + // and again before it expires. Lives here rather than in the app's + // wrapper so every place that mounts a player (the live dashboard + // included) gets it. + const myDid = useDID(); + const streamerDid = useLivestreamStoreOptional((x) => x.profile?.did); + const agent = usePDSAgent(); + const setPlaybackSession = usePlayerStore((x) => x.setPlaybackSession); + const isMine = + !!myDid && + (props.src === myDid || (!!streamerDid && streamerDid === myDid)); + useEffect(() => { + if (!isMine || !agent) { + setPlaybackSession(undefined); + return; + } + let cancelled = false; + let timer: ReturnType | null = null; + const fetchToken = async () => { + try { + const res = await agent.client.call( + place.stream.playback.getPlaybackSession, + ); + if (cancelled) return; + setPlaybackSession(res.sid); + const msLeft = new Date(res.expiresAt).getTime() - Date.now(); + timer = setTimeout(fetchToken, Math.max(60_000, msLeft - 5 * 60_000)); + } catch (e) { + if (cancelled) return; + console.warn("could not fetch a playback session", e); + timer = setTimeout(fetchToken, 60_000); + } + }; + void fetchToken(); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + setPlaybackSession(undefined); + }; + }, [isMine, agent, setPlaybackSession]); + // if we set muted, set it and restore after useEffect(() => { let wasMuted: null | boolean = null; @@ -89,12 +135,8 @@ export function Player( // Will call back every few seconds to send health updates usePlayerStatus(); - // A Player remounting over a hosted video that's already ingesting keeps - // that connection, and its state. - const ingestState = usePlayerStore((x) => x.ingestConnectionState); useEffect(() => { - if (!props.ingest) setIngest(null); - else if (ingestState === null) setIngest("new"); + setIngest(props.ingest ? "new" : null); }, []); useEffect(() => { diff --git a/js/components/src/components/mobile-player/video-host.tsx b/js/components/src/components/mobile-player/video-host.tsx deleted file mode 100644 index 27bc57f10..000000000 --- a/js/components/src/components/mobile-player/video-host.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { - createContext, - ReactNode, - useContext, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { createPortal } from "react-dom"; -import { LivestreamContext } from "../../livestream-store/context"; -import { PlayerContext } from "../../player-store/context"; -import { PlayerStore, usePlayerContext } from "../../player-store/player-store"; -import { VideoContext } from "../../video-store/context"; - -// A player's video (the
- {[...contents].map(([store, content]) => { - const h = hosted.get(store); - return h && createPortal(content, h.container, h.key); - })} - - ); -} - -// Shows the current player's video, rendered by the VideoHostProvider when -// the app has one, or in place when it doesn't. -export function HostedVideo({ children }: { children: ReactNode }) { - const host = useContext(VideoHostContext); - const players = usePlayerContext(); - const livestream = useContext(LivestreamContext); - const video = useContext(VideoContext); - const store = players.players[Object.keys(players.players)[0]]; - const box = useRef(null); - - useLayoutEffect(() => { - if (!host) return; - const el = box.current!; - host.attach(store, el); - return () => host.detach(store, el); - }, [host, store]); - - useLayoutEffect(() => { - // The host is above the screen's stores; pass their contexts along. - host?.render( - store, - - - - {children} - - - , - ); - }, [host, store, players, livestream, video, children]); - - if (!host) return <>{children}; - return
; -} diff --git a/js/components/tests/portrait-player.test.tsx b/js/components/tests/portrait-player.test.tsx index cd733cdf3..a4721533a 100644 --- a/js/components/tests/portrait-player.test.tsx +++ b/js/components/tests/portrait-player.test.tsx @@ -187,9 +187,6 @@ vi.mock("../src/components/mobile-player/video.native", () => ({ vi.mock("../src/components/mobile-player/ui/audio-only-overlay", () => ({ AudioOnlyOverlay: () => null, })); -vi.mock("../src/components/mobile-player/playback-session", () => ({ - usePlaybackSession: () => {}, -})); async function bindings() { const atoms = await import("../src/lib/theme/atoms"); diff --git a/js/e2e-web/flows/stream.spec.ts b/js/e2e-web/flows/stream.spec.ts index e184cf7d2..f9739f274 100644 --- a/js/e2e-web/flows/stream.spec.ts +++ b/js/e2e-web/flows/stream.spec.ts @@ -1,5 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; -import { loginThroughPds } from "./login"; +import { expect, test } from "@playwright/test"; // Mirror of .maestro/logged-out/stream.yaml: open the looping test stream from the home // feed and confirm playback context loads. The home feed card carries @@ -98,81 +97,3 @@ test("stream: portrait player exposes playback controls", async ({ page }) => { .toBe(false); await expect(page.getByLabel("Enter fullscreen")).toBeVisible(); }); - -// Crossing the desktop/mobile breakpoint moves the player to another branch -// of the layout. The video has to survive that: same element, still playing -// what it was (a WebRTC srcObject, or a src, which hls.js sets to a new blob: -// URL on the element or a child each time it starts over). -async function expectVideoSurvivesResize(page: Page) { - const video = page.locator("video").first(); - await expect - .poll(() => - video.evaluate((v: HTMLVideoElement) => !v.paused && v.readyState >= 2), - ) - .toBe(true); - await video.evaluate((v: HTMLVideoElement) => { - const src = v.src + (v.querySelector("source")?.src ?? ""); - (window as any).__spVideo = { el: v, src, srcObject: v.srcObject }; - }); - - const videoWidth = () => - page.evaluate( - () => document.querySelector("video")?.getBoundingClientRect().width, - ); - for (const width of [900, 1280]) { - const oldWidth = await videoWidth(); - await page.setViewportSize({ width, height: 720 }); - // The layout has caught up once the video's box changes. - await expect.poll(videoWidth).not.toBe(oldWidth); - const state = await page.evaluate(() => { - const before = (window as any).__spVideo; - const v = document.querySelector("video")!; - const src = v.src + (v.querySelector("source")?.src ?? ""); - return { - sameElement: v === before.el, - sameSource: src === before.src && v.srcObject === before.srcObject, - }; - }); - expect(state).toEqual({ sameElement: true, sameSource: true }); - await expect(video).toBeVisible(); - const before = await video.evaluate((v: HTMLVideoElement) => v.currentTime); - await expect - .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime)) - .toBeGreaterThan(before); - } -} - -test("stream: resizing the window keeps the same playing video", async ({ - page, -}) => { - await page.setViewportSize({ width: 1280, height: 720 }); - await page.goto("/"); - const card = page.getByTestId("home-stream-card").first(); - await expect(card).toBeVisible({ timeout: 30_000 }); - await card.click(); - await expectVideoSurvivesResize(page); -}); - -// The streamer watching their own stream over HLS plays it with their own -// playback session, which is part of the playlist URL. A layout change must -// not drop it, or the URL changes and hls.js starts over. -test("stream: the streamer's own HLS video survives a resize", async ({ - page, -}) => { - const httpsUrl = process.env.SERVER_HTTPS_URL; - test.skip(!httpsUrl, "harness started without its HTTPS hostnames"); - await page.setViewportSize({ width: 1280, height: 720 }); - await loginThroughPds(page); - await page.evaluate(() => localStorage.setItem("player-protocol", "hls")); - - await page.goto(`${httpsUrl}/`); - // The player's own master playlist URL, once it has the session (the - // node's variant playlists carry a sid of their own). hls.js attaches its - // new source in the same task as this request. - const sessionPlaylist = page.waitForRequest((r) => - /getLivePlaylist\?streamer=[^&]+&sid=/.test(r.url()), - ); - await page.getByTestId("home-stream-card").first().click(); - await sessionPlaylist; - await expectVideoSurvivesResize(page); -}); -- 2.54.0 From 0c130303042be3d667444a11927e37f0521470d3 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 14 Oct 2026 13:42:01 -0700 Subject: [PATCH 34/76] Revert "Revert "player: keep the web video alive across layout changes"" --- js/app/components/provider/provider.tsx | 6 +- .../mobile-player/fullscreen.native.tsx | 2 + .../components/mobile-player/fullscreen.tsx | 24 +++- .../mobile-player/playback-session.tsx | 50 +++++++ .../src/components/mobile-player/player.tsx | 52 +------ .../components/mobile-player/video-host.tsx | 129 ++++++++++++++++++ js/components/tests/portrait-player.test.tsx | 3 + js/e2e-web/flows/stream.spec.ts | 81 ++++++++++- 8 files changed, 292 insertions(+), 55 deletions(-) create mode 100644 js/components/src/components/mobile-player/playback-session.tsx create mode 100644 js/components/src/components/mobile-player/video-host.tsx diff --git a/js/app/components/provider/provider.tsx b/js/app/components/provider/provider.tsx index 2c8a59972..211c8f690 100644 --- a/js/app/components/provider/provider.tsx +++ b/js/app/components/provider/provider.tsx @@ -1,4 +1,5 @@ import { LinkingOptions } from "@react-navigation/native"; +import { VideoHostProvider } from "@streamplace/components/src/components/mobile-player/video-host"; import React, { useEffect } from "react"; import { SafeAreaProvider } from "react-native-safe-area-context"; import SharedProvider from "./provider.shared"; @@ -20,7 +21,10 @@ export default function Provider({ }, []); return ( - {children} + + {/* Owns players' ); } diff --git a/js/components/src/components/mobile-player/fullscreen.native.tsx b/js/components/src/components/mobile-player/fullscreen.native.tsx index 78cbbd56b..a584cd3ee 100644 --- a/js/components/src/components/mobile-player/fullscreen.native.tsx +++ b/js/components/src/components/mobile-player/fullscreen.native.tsx @@ -22,6 +22,7 @@ import { VideoRetry, } from "../.."; import { colors, surfaces } from "../../lib/theme/tokens"; +import { usePlaybackSession } from "./playback-session"; import { AudioOnlyOverlay } from "./ui/audio-only-overlay"; import Video from "./video.native"; @@ -31,6 +32,7 @@ export function Fullscreen(props: { objectFit?: "contain" | "cover"; pictureInPictureEnabled?: boolean; }) { + usePlaybackSession(props.src); const ref = useRef(null); const insets = useSafeAreaInsets(); const dimensions = useWindowDimensions(); diff --git a/js/components/src/components/mobile-player/fullscreen.tsx b/js/components/src/components/mobile-player/fullscreen.tsx index a977720ce..b877ef0de 100644 --- a/js/components/src/components/mobile-player/fullscreen.tsx +++ b/js/components/src/components/mobile-player/fullscreen.tsx @@ -11,8 +11,10 @@ import { usePlayerStore, } from "../.."; import { View } from "../../components/ui"; +import { usePlaybackSession } from "./playback-session"; import { AudioOnlyOverlay } from "./ui/audio-only-overlay"; import Video from "./video"; +import { HostedVideo } from "./video-host"; import VideoRetry from "./video-retry"; export function Fullscreen(props: { @@ -123,12 +125,15 @@ export function Fullscreen(props: { ref={divRef} style={{ width: "100%", height: "100%", overflow: "hidden" }} > - - + + + + + ); } + +// Hosted with the video, so a layout change that remounts this Fullscreen +// doesn't drop the session and change the video's URL. +function PlaybackSession(props: { src: string }) { + usePlaybackSession(props.src); + return null; +} diff --git a/js/components/src/components/mobile-player/playback-session.tsx b/js/components/src/components/mobile-player/playback-session.tsx new file mode 100644 index 000000000..f78542e3f --- /dev/null +++ b/js/components/src/components/mobile-player/playback-session.tsx @@ -0,0 +1,50 @@ +import { useEffect } from "react"; +import { place } from "streamplace"; +import { useLivestreamStoreOptional } from "../../livestream-store/use-store"; +import { usePlayerStore } from "../../player-store"; +import { useDID } from "../../streamplace-store"; +import { usePDSAgent } from "../../streamplace-store/xrpc"; + +// Pre-live over HLS: the streamer previewing their own stream needs their +// own playback session (an anonymous one opens published streams only). +// It only opens the caller's own stream, so ask when this is that viewer, +// and again before it expires. Fullscreen calls this next to the video so +// every place that mounts a player (the live dashboard included) gets it, +// and so the session lasts as long as the video whose URL carries it. +export function usePlaybackSession(src: string) { + const myDid = useDID(); + const streamerDid = useLivestreamStoreOptional((x) => x.profile?.did); + const agent = usePDSAgent(); + const setPlaybackSession = usePlayerStore((x) => x.setPlaybackSession); + const isMine = + !!myDid && (src === myDid || (!!streamerDid && streamerDid === myDid)); + useEffect(() => { + if (!isMine || !agent) { + setPlaybackSession(undefined); + return; + } + let cancelled = false; + let timer: ReturnType | null = null; + const fetchToken = async () => { + try { + const res = await agent.client.call( + place.stream.playback.getPlaybackSession, + ); + if (cancelled) return; + setPlaybackSession(res.sid); + const msLeft = new Date(res.expiresAt).getTime() - Date.now(); + timer = setTimeout(fetchToken, Math.max(60_000, msLeft - 5 * 60_000)); + } catch (e) { + if (cancelled) return; + console.warn("could not fetch a playback session", e); + timer = setTimeout(fetchToken, 60_000); + } + }; + void fetchToken(); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + setPlaybackSession(undefined); + }; + }, [isMine, agent, setPlaybackSession]); +} diff --git a/js/components/src/components/mobile-player/player.tsx b/js/components/src/components/mobile-player/player.tsx index 4cf49ddfd..f740924cc 100644 --- a/js/components/src/components/mobile-player/player.tsx +++ b/js/components/src/components/mobile-player/player.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from "react"; -import { place } from "streamplace"; import { flex, h, layout, w, zIndex } from "../../lib/theme/atoms"; import { useLivestreamStoreOptional } from "../../livestream-store/use-store"; import { @@ -8,12 +7,10 @@ import { usePlayerStore, } from "../../player-store"; import { - useDID, useMuted, useSetMuted, useStreamplaceStore, } from "../../streamplace-store"; -import { usePDSAgent } from "../../streamplace-store/xrpc"; import { Text, View } from "../ui"; import { Fullscreen } from "./fullscreen"; import { PlayerProps } from "./props"; @@ -59,49 +56,6 @@ export function Player( } }, [streamHasBFrames, streamForcesHLS, protocol, setStreamForcesHLS]); - // Pre-live over HLS: the streamer previewing their own stream needs their - // own playback session (an anonymous one opens published streams only). - // It only opens the caller's own stream, so ask when this is that viewer, - // and again before it expires. Lives here rather than in the app's - // wrapper so every place that mounts a player (the live dashboard - // included) gets it. - const myDid = useDID(); - const streamerDid = useLivestreamStoreOptional((x) => x.profile?.did); - const agent = usePDSAgent(); - const setPlaybackSession = usePlayerStore((x) => x.setPlaybackSession); - const isMine = - !!myDid && - (props.src === myDid || (!!streamerDid && streamerDid === myDid)); - useEffect(() => { - if (!isMine || !agent) { - setPlaybackSession(undefined); - return; - } - let cancelled = false; - let timer: ReturnType | null = null; - const fetchToken = async () => { - try { - const res = await agent.client.call( - place.stream.playback.getPlaybackSession, - ); - if (cancelled) return; - setPlaybackSession(res.sid); - const msLeft = new Date(res.expiresAt).getTime() - Date.now(); - timer = setTimeout(fetchToken, Math.max(60_000, msLeft - 5 * 60_000)); - } catch (e) { - if (cancelled) return; - console.warn("could not fetch a playback session", e); - timer = setTimeout(fetchToken, 60_000); - } - }; - void fetchToken(); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - setPlaybackSession(undefined); - }; - }, [isMine, agent, setPlaybackSession]); - // if we set muted, set it and restore after useEffect(() => { let wasMuted: null | boolean = null; @@ -135,8 +89,12 @@ export function Player( // Will call back every few seconds to send health updates usePlayerStatus(); + // A Player remounting over a hosted video that's already ingesting keeps + // that connection, and its state. + const ingestState = usePlayerStore((x) => x.ingestConnectionState); useEffect(() => { - setIngest(props.ingest ? "new" : null); + if (!props.ingest) setIngest(null); + else if (ingestState === null) setIngest("new"); }, []); useEffect(() => { diff --git a/js/components/src/components/mobile-player/video-host.tsx b/js/components/src/components/mobile-player/video-host.tsx new file mode 100644 index 000000000..27bc57f10 --- /dev/null +++ b/js/components/src/components/mobile-player/video-host.tsx @@ -0,0 +1,129 @@ +import { + createContext, + ReactNode, + useContext, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import { LivestreamContext } from "../../livestream-store/context"; +import { PlayerContext } from "../../player-store/context"; +import { PlayerStore, usePlayerContext } from "../../player-store/player-store"; +import { VideoContext } from "../../video-store/context"; + +// A player's video (the
+ {[...contents].map(([store, content]) => { + const h = hosted.get(store); + return h && createPortal(content, h.container, h.key); + })} + + ); +} + +// Shows the current player's video, rendered by the VideoHostProvider when +// the app has one, or in place when it doesn't. +export function HostedVideo({ children }: { children: ReactNode }) { + const host = useContext(VideoHostContext); + const players = usePlayerContext(); + const livestream = useContext(LivestreamContext); + const video = useContext(VideoContext); + const store = players.players[Object.keys(players.players)[0]]; + const box = useRef(null); + + useLayoutEffect(() => { + if (!host) return; + const el = box.current!; + host.attach(store, el); + return () => host.detach(store, el); + }, [host, store]); + + useLayoutEffect(() => { + // The host is above the screen's stores; pass their contexts along. + host?.render( + store, + + + + {children} + + + , + ); + }, [host, store, players, livestream, video, children]); + + if (!host) return <>{children}; + return
; +} diff --git a/js/components/tests/portrait-player.test.tsx b/js/components/tests/portrait-player.test.tsx index a4721533a..cd733cdf3 100644 --- a/js/components/tests/portrait-player.test.tsx +++ b/js/components/tests/portrait-player.test.tsx @@ -187,6 +187,9 @@ vi.mock("../src/components/mobile-player/video.native", () => ({ vi.mock("../src/components/mobile-player/ui/audio-only-overlay", () => ({ AudioOnlyOverlay: () => null, })); +vi.mock("../src/components/mobile-player/playback-session", () => ({ + usePlaybackSession: () => {}, +})); async function bindings() { const atoms = await import("../src/lib/theme/atoms"); diff --git a/js/e2e-web/flows/stream.spec.ts b/js/e2e-web/flows/stream.spec.ts index f9739f274..e184cf7d2 100644 --- a/js/e2e-web/flows/stream.spec.ts +++ b/js/e2e-web/flows/stream.spec.ts @@ -1,4 +1,5 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; +import { loginThroughPds } from "./login"; // Mirror of .maestro/logged-out/stream.yaml: open the looping test stream from the home // feed and confirm playback context loads. The home feed card carries @@ -97,3 +98,81 @@ test("stream: portrait player exposes playback controls", async ({ page }) => { .toBe(false); await expect(page.getByLabel("Enter fullscreen")).toBeVisible(); }); + +// Crossing the desktop/mobile breakpoint moves the player to another branch +// of the layout. The video has to survive that: same element, still playing +// what it was (a WebRTC srcObject, or a src, which hls.js sets to a new blob: +// URL on the element or a child each time it starts over). +async function expectVideoSurvivesResize(page: Page) { + const video = page.locator("video").first(); + await expect + .poll(() => + video.evaluate((v: HTMLVideoElement) => !v.paused && v.readyState >= 2), + ) + .toBe(true); + await video.evaluate((v: HTMLVideoElement) => { + const src = v.src + (v.querySelector("source")?.src ?? ""); + (window as any).__spVideo = { el: v, src, srcObject: v.srcObject }; + }); + + const videoWidth = () => + page.evaluate( + () => document.querySelector("video")?.getBoundingClientRect().width, + ); + for (const width of [900, 1280]) { + const oldWidth = await videoWidth(); + await page.setViewportSize({ width, height: 720 }); + // The layout has caught up once the video's box changes. + await expect.poll(videoWidth).not.toBe(oldWidth); + const state = await page.evaluate(() => { + const before = (window as any).__spVideo; + const v = document.querySelector("video")!; + const src = v.src + (v.querySelector("source")?.src ?? ""); + return { + sameElement: v === before.el, + sameSource: src === before.src && v.srcObject === before.srcObject, + }; + }); + expect(state).toEqual({ sameElement: true, sameSource: true }); + await expect(video).toBeVisible(); + const before = await video.evaluate((v: HTMLVideoElement) => v.currentTime); + await expect + .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime)) + .toBeGreaterThan(before); + } +} + +test("stream: resizing the window keeps the same playing video", async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/"); + const card = page.getByTestId("home-stream-card").first(); + await expect(card).toBeVisible({ timeout: 30_000 }); + await card.click(); + await expectVideoSurvivesResize(page); +}); + +// The streamer watching their own stream over HLS plays it with their own +// playback session, which is part of the playlist URL. A layout change must +// not drop it, or the URL changes and hls.js starts over. +test("stream: the streamer's own HLS video survives a resize", async ({ + page, +}) => { + const httpsUrl = process.env.SERVER_HTTPS_URL; + test.skip(!httpsUrl, "harness started without its HTTPS hostnames"); + await page.setViewportSize({ width: 1280, height: 720 }); + await loginThroughPds(page); + await page.evaluate(() => localStorage.setItem("player-protocol", "hls")); + + await page.goto(`${httpsUrl}/`); + // The player's own master playlist URL, once it has the session (the + // node's variant playlists carry a sid of their own). hls.js attaches its + // new source in the same task as this request. + const sessionPlaylist = page.waitForRequest((r) => + /getLivePlaylist\?streamer=[^&]+&sid=/.test(r.url()), + ); + await page.getByTestId("home-stream-card").first().click(); + await sessionPlaylist; + await expectVideoSurvivesResize(page); +}); -- 2.54.0 From 19a9d5824c6ae559c612bafc28d524d322a44c0f Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 14 Oct 2026 14:14:33 -0700 Subject: [PATCH 35/76] player: keep a failed autoplay's play button across layout changes Fullscreen remounts with the layout, and its src effect cleared autoplayFailed every time. The hosted video survives the remount and doesn't retry, so the play button disappeared and left a stopped video. Setting the source and resetting the flag now live in the hosted subtree, with the playback session, so they follow the video. The play button gets a button role and label, which the new autoplay-blocked e2e uses. VideoHostProvider is exported from the package barrel. --- js/app/components/provider/provider.tsx | 2 +- .../components/mobile-player/fullscreen.tsx | 21 +++++---- .../mobile-player/ui/autoplay-button.tsx | 2 + js/components/src/index.tsx | 1 + js/e2e-web/flows/autoplay-blocked.spec.ts | 47 +++++++++++++++++++ 5 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 js/e2e-web/flows/autoplay-blocked.spec.ts diff --git a/js/app/components/provider/provider.tsx b/js/app/components/provider/provider.tsx index 211c8f690..5f2ad063b 100644 --- a/js/app/components/provider/provider.tsx +++ b/js/app/components/provider/provider.tsx @@ -1,5 +1,5 @@ import { LinkingOptions } from "@react-navigation/native"; -import { VideoHostProvider } from "@streamplace/components/src/components/mobile-player/video-host"; +import { VideoHostProvider } from "@streamplace/components"; import React, { useEffect } from "react"; import { SafeAreaProvider } from "react-native-safe-area-context"; import SharedProvider from "./provider.shared"; diff --git a/js/components/src/components/mobile-player/fullscreen.tsx b/js/components/src/components/mobile-player/fullscreen.tsx index b877ef0de..1ffcc4305 100644 --- a/js/components/src/components/mobile-player/fullscreen.tsx +++ b/js/components/src/components/mobile-player/fullscreen.tsx @@ -27,8 +27,6 @@ export function Fullscreen(props: { const protocol = usePlayerStore((x) => x.protocol, playerId); const fullscreen = usePlayerStore((x) => x.fullscreen, playerId); const setFullscreen = usePlayerStore((x) => x.setFullscreen, playerId); - const setSrc = usePlayerStore((x) => x.setSrc); - const setAutoplayFailed = usePlayerStore((x) => x.setAutoplayFailed); const danmuEnabled = useDanmuEnabled(); const danmuOpacity = useDanmuOpacity(); @@ -49,11 +47,6 @@ export function Fullscreen(props: { return null; }; - useEffect(() => { - setSrc(props.src); - setAutoplayFailed(false); - }, [props.src]); - useEffect(() => { if (!divRef.current) { return; @@ -126,7 +119,7 @@ export function Fullscreen(props: { style={{ width: "100%", height: "100%", overflow: "hidden" }} > - + + +### `main` + +**Type:** `query` + +Clip a livestream between two instants as a flat (faststart) MP4. The clip is cut on MUXL segment boundaries: it holds every segment whose start falls within [start, end], so it may begin up to one segment late and run up to one segment past end. Segments come from the node's livestream recordings and from the recent stream it holds in memory; each segment's C2PA signature passes through verbatim. Only published (live) segments are clipped. Requires a signed-in caller: an OAuth session, or a service-auth token bound to this method (lxm), so third-party apps can call it through the PDS with the rpc:place.stream.live.getClip scope. + +**Parameters:** + +| Name | Type | Req'd | Description | Constraints | +| ---------- | -------- | ----- | ------------------------------------------------------------------------------- | ------------------ | +| `streamer` | `string` | ✅ | The streamer: a DID or a Bluesky handle (resolved to its DID). | | +| `start` | `string` | ✅ | Start of the clip (wall-clock time). | Format: `datetime` | +| `end` | `string` | ✅ | End of the clip (wall-clock time). Must be after start, at most one hour later. | Format: `datetime` | + +**Output:** + +- **Encoding:** `video/mp4` +- **Schema:** + +_Schema not defined._ +**Possible Errors:** + +- `ClipNotFound`: This node has no published segments of the stream in the requested range. +- `ClipUnavailable`: Part of the requested range is still being recorded and is held only by the node the streamer is streaming to; retry there, or later once the recording has been written. + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.live.getClip", + "defs": { + "main": { + "type": "query", + "description": "Clip a livestream between two instants as a flat (faststart) MP4. The clip is cut on MUXL segment boundaries: it holds every segment whose start falls within [start, end], so it may begin up to one segment late and run up to one segment past end. Segments come from the node's livestream recordings and from the recent stream it holds in memory; each segment's C2PA signature passes through verbatim. Only published (live) segments are clipped. Requires a signed-in caller: an OAuth session, or a service-auth token bound to this method (lxm), so third-party apps can call it through the PDS with the rpc:place.stream.live.getClip scope.", + "parameters": { + "type": "params", + "required": ["streamer", "start", "end"], + "properties": { + "streamer": { + "type": "string", + "description": "The streamer: a DID or a Bluesky handle (resolved to its DID)." + }, + "start": { + "type": "string", + "format": "datetime", + "description": "Start of the clip (wall-clock time)." + }, + "end": { + "type": "string", + "format": "datetime", + "description": "End of the clip (wall-clock time). Must be after start, at most one hour later." + } + } + }, + "output": { + "encoding": "video/mp4" + }, + "errors": [ + { + "name": "ClipNotFound", + "description": "This node has no published segments of the stream in the requested range." + }, + { + "name": "ClipUnavailable", + "description": "Part of the requested range is still being recorded and is held only by the node the streamer is streaming to; retry there, or later once the recording has been written." + } + ] + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index 3e4268ca5..73f18aef1 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -3511,6 +3511,84 @@ } } }, + "/xrpc/place.stream.live.getClip": { + "get": { + "summary": "Clip a livestream between two instants as a flat (faststart) MP4. The clip is cut on MUXL segment boundaries: it holds every segment whose start falls within [start, end], so it may begin up to one segment late and run up to one segment past end. Segments come from the node's livestream recordings and from the recent stream it holds in memory; each segment's C2PA signature passes through verbatim. Only published (live) segments are clipped. Requires a signed-in caller: an OAuth session, or a service-auth token bound to this method (lxm), so third-party apps can call it through the PDS with the rpc:place.stream.live.getClip scope.", + "operationId": "place.stream.live.getClip", + "tags": ["place.stream.live"], + "responses": { + "200": { + "description": "Success", + "content": { + "video/mp4": { + "schema": {} + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["error", "message"], + "properties": { + "error": { + "type": "string", + "oneOf": [ + { + "const": "ClipNotFound" + }, + { + "const": "ClipUnavailable" + } + ] + }, + "message": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "streamer", + "in": "query", + "required": true, + "description": "The streamer: a DID or a Bluesky handle (resolved to its DID).", + "schema": { + "type": "string", + "description": "The streamer: a DID or a Bluesky handle (resolved to its DID)." + } + }, + { + "name": "start", + "in": "query", + "required": true, + "description": "Start of the clip (wall-clock time).", + "schema": { + "type": "string", + "description": "Start of the clip (wall-clock time).", + "format": "date-time" + } + }, + { + "name": "end", + "in": "query", + "required": true, + "description": "End of the clip (wall-clock time). Must be after start, at most one hour later.", + "schema": { + "type": "string", + "description": "End of the clip (wall-clock time). Must be after start, at most one hour later.", + "format": "date-time" + } + } + ] + } + }, "/xrpc/place.stream.live.getLiveUsers": { "get": { "summary": "Get a list of livestream segments for a user", diff --git a/js/e2e-web/flows/live-clip.spec.ts b/js/e2e-web/flows/live-clip.spec.ts new file mode 100644 index 000000000..3de08fa52 --- /dev/null +++ b/js/e2e-web/flows/live-clip.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from "@playwright/test"; + +// place.stream.live.getClip, called the way a third-party app does: with a +// service-auth token the user's PDS mints for exactly this method (the rpc +// scope), not the node's own OAuth session. The clip of the harness's live +// stream must be a playable MP4. Backend-only for now: there is no app surface +// yet, so there is no mobile counterpart. +test("live-clip: a service-auth caller clips the live stream to a playable MP4", async ({ + page, +}) => { + const pdsUrl = process.env.PDS_HTTPS_URL; + test.skip(!pdsUrl, "needs the harness's HTTPS mode for the PDS"); + const did = process.env.ACCOUNT_DID!; + + await page.goto("/"); + await expect(page.getByTestId("home-stream-card").first()).toBeVisible({ + timeout: 30_000, + }); + + // Everything runs in the page: the browser is what trusts the harness's + // certificates, and a browser is where a third-party web app would call from. + const result = await page.evaluate( + async ({ pdsUrl, handle, password, did }) => { + const nodeDid = (await (await fetch("/.well-known/did.json")).json()).id; + const session = await ( + await fetch(`${pdsUrl}/xrpc/com.atproto.server.createSession`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: handle, password }), + }) + ).json(); + const lxm = "place.stream.live.getClip"; + const { token } = await ( + await fetch( + `${pdsUrl}/xrpc/com.atproto.server.getServiceAuth?aud=${encodeURIComponent(nodeDid)}&lxm=${lxm}`, + { headers: { Authorization: `Bearer ${session.accessJwt}` } }, + ) + ).json(); + + const end = new Date(); + const start = new Date(end.getTime() - 20_000); + const url = `/xrpc/${lxm}?streamer=${encodeURIComponent(did)}&start=${start.toISOString()}&end=${end.toISOString()}`; + const anonymous = (await fetch(url)).status; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + const bytes = new Uint8Array(await res.arrayBuffer()); + const contentLength = res.headers.get("content-length"); + + // A plain
+
0 + ? Math.round((Math.max(0, startsAt - now) / initialTimeLeft) * 100) + : 0 + } + > +
0 + ? Math.max( + 0, + Math.min(100, ((startsAt - now) / initialTimeLeft) * 100), + ) + : 0 + }%`, + }} + /> +
+ ); } diff --git a/js/web/src/components/stream/teleport-dialog.tsx b/js/web/src/components/stream/teleport-dialog.tsx new file mode 100644 index 000000000..16bebc32d --- /dev/null +++ b/js/web/src/components/stream/teleport-dialog.tsx @@ -0,0 +1,182 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import useAvatars from "@/hooks/use-avatars"; +import { useLiveUsers } from "@/hooks/use-live-users"; +import { useMemo, useState } from "react"; + +export function TeleportDialog({ + open, + onOpenChange, + onSubmit, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (handle: string, countdownSeconds: number) => Promise; +}) { + const [query, setQuery] = useState(""); + const [countdown, setCountdown] = useState("10"); + const [selected, setSelected] = useState(null); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const { data: liveUsers = [], isLoading } = useLiveUsers(); + const authorDids = useMemo( + () => + liveUsers.map((stream) => stream.author?.did).filter(Boolean) as string[], + [liveUsers], + ); + const profiles = useAvatars(authorDids); + const filteredUsers = useMemo(() => { + const filter = query.trim().toLowerCase(); + if (!filter) return liveUsers; + return liveUsers.filter( + (stream) => + stream.author?.handle?.toLowerCase().includes(filter) || + String(stream.record.title || "") + .toLowerCase() + .includes(filter), + ); + }, [liveUsers, query]); + + const close = () => { + setQuery(""); + setCountdown("10"); + setSelected(null); + setError(null); + onOpenChange(false); + }; + + const submit = async () => { + const stream = liveUsers.find((candidate) => candidate.uri === selected); + const seconds = Number.parseInt(countdown, 10); + if (!stream?.author?.handle) return; + if (!Number.isInteger(seconds) || seconds < 5 || seconds > 300) { + setError("Countdown must be between 5 and 300 seconds."); + return; + } + setSubmitting(true); + setError(null); + try { + await onSubmit(stream.author.handle, seconds); + close(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Teleport failed."); + } finally { + setSubmitting(false); + } + }; + + return ( + !nextOpen && close()}> + + + Teleport to another live streamer + + Select a streamer to teleport your viewers to their stream. + + + +
+ + + {isLoading && liveUsers.length === 0 ? ( +

+ Loading live streamers… +

+ ) : filteredUsers.length === 0 ? ( +

+ {liveUsers.length === 0 + ? "No live streamers found." + : "No matching live streamers found."} +

+ ) : ( +
+ {filteredUsers.map((stream) => { + const did = stream.author?.did; + const handle = stream.author?.handle || did || "Unknown"; + const profile = did ? profiles[did] : undefined; + const isSelected = stream.uri === selected; + return ( + + ); + })} +
+ )} + + + {error && ( +

+ {error} +

+ )} +
+ + + + + +
+
+ ); +} diff --git a/js/web/src/routes/$user/index.tsx b/js/web/src/routes/$user/index.tsx index 638f41adc..e2a5eeec8 100644 --- a/js/web/src/routes/$user/index.tsx +++ b/js/web/src/routes/$user/index.tsx @@ -4,6 +4,7 @@ import { CopyButton, StreamInfo, } from "@/components/stream/stream-info"; +import { StreamTeleportNotification } from "@/components/stream/stream-notifications"; import { VideoSection } from "@/components/stream/video-section"; import { Button, buttonVariants } from "@/components/ui/button"; import { @@ -15,12 +16,13 @@ import { } from "@/components/ui/sheet"; import { useFullscreen } from "@/contexts/fullscreen-context"; import { useActorLookup } from "@/hooks/actor-lookup"; +import useAvatars from "@/hooks/use-avatars"; import { useLivenessState } from "@/hooks/use-liveness-state"; import { useLivestreamStore } from "@/hooks/use-livestream-store"; import { useStreamAvatar } from "@/hooks/use-stream-avatar"; import { useStreamplaceUrl } from "@/lib/store/hooks"; import type { LivestreamStore } from "@streamplace/core"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { ExternalLink, House, @@ -29,7 +31,7 @@ import { Search, X, } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; @@ -140,9 +142,32 @@ function CreatorState({ } function StreamBody({ store, user }: { store: LivestreamStore; user: string }) { + const navigate = useNavigate(); const liveness = useLivenessState(store); const avatar = useStreamAvatar(store); + const activeTeleport = useStore(store, (s) => s.activeTeleport); + const livestreamDid = useStore(store, (s) => s.livestream?.author.did); + const teleportDids = useMemo( + () => (activeTeleport ? [activeTeleport.streamer] : []), + [activeTeleport?.streamer], + ); + const teleportProfiles = useAvatars(teleportDids); const { theatre } = useFullscreen(); + + useEffect(() => { + if (!activeTeleport || activeTeleport.streamer === livestreamDid) return; + const targetDid = activeTeleport.streamer; + const startsAt = new Date(activeTeleport.startsAt).getTime(); + if (!Number.isFinite(startsAt)) return; + const timer = setTimeout( + () => { + const target = teleportProfiles[targetDid]?.handle || targetDid; + void navigate({ to: "/$user", params: { user: target } }); + }, + Math.max(0, startsAt - Date.now()), + ); + return () => clearTimeout(timer); + }, [activeTeleport, livestreamDid, navigate, teleportProfiles]); // Offline streams default to a closed chat; the chat has nothing // to show, and the shorter offline player should take the page // width. Live streams honor the user's saved preference. @@ -207,6 +232,7 @@ function StreamBody({ store, user }: { store: LivestreamStore; user: string }) { return (
+
Date: Wed, 30 Sep 2026 16:47:01 -0500 Subject: [PATCH 59/76] feat(web): add contained stream notifications --- js/app/package.json | 6 +- js/i18n/locales/es-ES/common.ftl | 4 + js/i18n/locales/fr-FR/common.ftl | 4 + js/i18n/locales/pt-BR/common.ftl | 4 + js/i18n/locales/ro-RO/common.ftl | 4 + js/i18n/locales/zh-Hans/common.ftl | 4 + js/i18n/locales/zh-Hant/common.ftl | 4 + js/i18n/public/locales/en-US/common.json | 388 ------------------ js/i18n/public/locales/en-US/settings.json | 363 ---------------- js/i18n/public/locales/es-ES/common.json | 48 --- js/i18n/public/locales/es-ES/settings.json | 251 ----------- js/i18n/public/locales/fr-FR/common.json | 48 --- js/i18n/public/locales/fr-FR/settings.json | 251 ----------- js/i18n/public/locales/pt-BR/common.json | 48 --- js/i18n/public/locales/pt-BR/settings.json | 251 ----------- js/i18n/public/locales/ro-RO/common.json | 48 --- js/i18n/public/locales/ro-RO/settings.json | 211 ---------- js/i18n/public/locales/zh-Hans/common.json | 48 --- js/i18n/public/locales/zh-Hans/settings.json | 246 ----------- js/i18n/public/locales/zh-Hant/common.json | 48 --- js/i18n/public/locales/zh-Hant/settings.json | 251 ----------- js/web/package.json | 6 +- js/web/public/locales/en-US/common.json | 388 ------------------ js/web/public/locales/en-US/settings.json | 363 ---------------- js/web/public/locales/es-ES/common.json | 48 --- js/web/public/locales/es-ES/settings.json | 251 ----------- js/web/public/locales/fr-FR/common.json | 48 --- js/web/public/locales/fr-FR/settings.json | 251 ----------- js/web/public/locales/pt-BR/common.json | 48 --- js/web/public/locales/pt-BR/settings.json | 251 ----------- js/web/public/locales/ro-RO/common.json | 48 --- js/web/public/locales/ro-RO/settings.json | 211 ---------- js/web/public/locales/zh-Hans/common.json | 48 --- js/web/public/locales/zh-Hans/settings.json | 246 ----------- js/web/public/locales/zh-Hant/common.json | 48 --- js/web/public/locales/zh-Hant/settings.json | 251 ----------- .../stream/stream-notification-provider.tsx | 51 +++ .../stream/stream-notifications.tsx | 23 +- .../src/components/stream/video-section.tsx | 3 + js/web/src/lib/i18n.ts | 2 + js/web/src/lib/stream-notification.ts | 83 ++++ 41 files changed, 191 insertions(+), 5007 deletions(-) delete mode 100644 js/i18n/public/locales/en-US/common.json delete mode 100644 js/i18n/public/locales/en-US/settings.json delete mode 100644 js/i18n/public/locales/es-ES/common.json delete mode 100644 js/i18n/public/locales/es-ES/settings.json delete mode 100644 js/i18n/public/locales/fr-FR/common.json delete mode 100644 js/i18n/public/locales/fr-FR/settings.json delete mode 100644 js/i18n/public/locales/pt-BR/common.json delete mode 100644 js/i18n/public/locales/pt-BR/settings.json delete mode 100644 js/i18n/public/locales/ro-RO/common.json delete mode 100644 js/i18n/public/locales/ro-RO/settings.json delete mode 100644 js/i18n/public/locales/zh-Hans/common.json delete mode 100644 js/i18n/public/locales/zh-Hans/settings.json delete mode 100644 js/i18n/public/locales/zh-Hant/common.json delete mode 100644 js/i18n/public/locales/zh-Hant/settings.json delete mode 100644 js/web/public/locales/en-US/common.json delete mode 100644 js/web/public/locales/en-US/settings.json delete mode 100644 js/web/public/locales/es-ES/common.json delete mode 100644 js/web/public/locales/es-ES/settings.json delete mode 100644 js/web/public/locales/fr-FR/common.json delete mode 100644 js/web/public/locales/fr-FR/settings.json delete mode 100644 js/web/public/locales/pt-BR/common.json delete mode 100644 js/web/public/locales/pt-BR/settings.json delete mode 100644 js/web/public/locales/ro-RO/common.json delete mode 100644 js/web/public/locales/ro-RO/settings.json delete mode 100644 js/web/public/locales/zh-Hans/common.json delete mode 100644 js/web/public/locales/zh-Hans/settings.json delete mode 100644 js/web/public/locales/zh-Hant/common.json delete mode 100644 js/web/public/locales/zh-Hant/settings.json create mode 100644 js/web/src/components/stream/stream-notification-provider.tsx create mode 100644 js/web/src/lib/stream-notification.ts diff --git a/js/app/package.json b/js/app/package.json index 09d3b8dd5..eb214d42f 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -7,17 +7,17 @@ "version": "0.12.6", "runtimeVersion": "0.12.0", "scripts": { - "start": "npx expo start -c --port 38081", + "start": "pnpm --filter @streamplace/i18n compile && npx expo start -c --port 38081", "start:fast": "npx expo start --port 38081", "dev:ios": "bash scripts/dev-ios.sh", "ui:shoot": "bash scripts/ui-shoot.sh", "android": "expo run:android --no-bundler", "ios": "expo run:ios --no-bundler", - "web": "npx expo start --web --port 38081", + "web": "pnpm --filter @streamplace/i18n compile && npx expo start --web --port 38081", "test": "jest --watchAll", "brand": "pnpm --filter @streamplace/brand generate", "build": "pnpm run build:web && pnpm run prebuild", - "build:web": "pnpm run brand && node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json", + "build:web": "pnpm --filter @streamplace/i18n compile && pnpm run brand && node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json", "export": "(expo export --dump-sourcemap || expo export --dump-sourcemap) && node scripts/bootstrap-web.mjs", "analyze:web": "pnpm run brand && EXPO_ATLAS=true expo export --platform web --output-dir .expo/atlas-web --dump-sourcemap && node scripts/bootstrap-web.mjs .expo/atlas-web && expo-atlas --no-open", "check": "pnpm --filter @streamplace/i18n compile && bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'", diff --git a/js/i18n/locales/es-ES/common.ftl b/js/i18n/locales/es-ES/common.ftl index 56e240366..8d4b1a5dc 100644 --- a/js/i18n/locales/es-ES/common.ftl +++ b/js/i18n/locales/es-ES/common.ftl @@ -76,3 +76,7 @@ pds-selector-handle-policy-checkbox = He leído y acepto la polític ## Login login-show-live-on-bluesky = Mostrar cuando estoy en directo en Bluesky login-show-live-on-bluesky-description = Añade el anillo rojo LIVE a tu avatar de Bluesky mientras transmites y permite que Streamplace publique anuncios por ti. Desmárcalo para iniciar sesión sin conceder ningún acceso a tu cuenta de Bluesky. + +## Stream notifications +teleporting-in = Teletransportando en +teleporting-to = Teletransportando a @{ $handle } diff --git a/js/i18n/locales/fr-FR/common.ftl b/js/i18n/locales/fr-FR/common.ftl index 5b851b48f..9cf84e54d 100644 --- a/js/i18n/locales/fr-FR/common.ftl +++ b/js/i18n/locales/fr-FR/common.ftl @@ -76,3 +76,7 @@ pds-selector-handle-policy-checkbox = J'ai lu et j'accepte la politi ## Login login-show-live-on-bluesky = Afficher quand je suis en direct sur Bluesky login-show-live-on-bluesky-description = Ajoute l'anneau rouge LIVE à votre avatar Bluesky pendant vos streams et permet à Streamplace de publier des annonces pour vous. Décochez la case pour vous connecter sans accorder aucun accès à votre compte Bluesky. + +## Stream notifications +teleporting-in = Téléportation dans +teleporting-to = Téléportation vers @{ $handle } diff --git a/js/i18n/locales/pt-BR/common.ftl b/js/i18n/locales/pt-BR/common.ftl index 2809d4690..96adf49f7 100644 --- a/js/i18n/locales/pt-BR/common.ftl +++ b/js/i18n/locales/pt-BR/common.ftl @@ -76,3 +76,7 @@ pds-selector-handle-policy-checkbox = Li e concordo com a política ## Login login-show-live-on-bluesky = Mostrar quando estou ao vivo no Bluesky login-show-live-on-bluesky-description = Adiciona o anel vermelho LIVE ao seu avatar do Bluesky enquanto você transmite e permite que o Streamplace publique anúncios por você. Desmarque para entrar sem conceder nenhum acesso à sua conta do Bluesky. + +## Stream notifications +teleporting-in = Teletransportando em +teleporting-to = Teletransportando para @{ $handle } diff --git a/js/i18n/locales/ro-RO/common.ftl b/js/i18n/locales/ro-RO/common.ftl index 13b04bc3b..0187c4b35 100644 --- a/js/i18n/locales/ro-RO/common.ftl +++ b/js/i18n/locales/ro-RO/common.ftl @@ -76,3 +76,7 @@ pds-selector-handle-policy-checkbox = Am citit și sunt de acord cu ## Login login-show-live-on-bluesky = Arată când sunt live pe Bluesky login-show-live-on-bluesky-description = Adaugă inelul roșu LIVE avatarului dvs. de Bluesky în timp ce transmiteți și permite Streamplace să publice anunțuri în numele dvs. Debifați pentru a vă conecta fără a acorda niciun acces la contul dvs. de Bluesky. + +## Stream notifications +teleporting-in = Teleportare în +teleporting-to = Teleportare către @{ $handle } diff --git a/js/i18n/locales/zh-Hans/common.ftl b/js/i18n/locales/zh-Hans/common.ftl index abf41d910..c45a12883 100644 --- a/js/i18n/locales/zh-Hans/common.ftl +++ b/js/i18n/locales/zh-Hans/common.ftl @@ -71,3 +71,7 @@ pds-selector-handle-policy-checkbox = 我已阅读并同意处理政 ## Login login-show-live-on-bluesky = 在 Bluesky 上显示我的直播状态 login-show-live-on-bluesky-description = 直播时为您的 Bluesky 头像加上红色 LIVE 圆环,并允许 Streamplace 代您发布直播公告。取消勾选即可在不授予任何 Bluesky 账号访问权限的情况下登录。 + +## Stream notifications +teleporting-in = 传送倒计时 +teleporting-to = 正在传送至 @{ $handle } diff --git a/js/i18n/locales/zh-Hant/common.ftl b/js/i18n/locales/zh-Hant/common.ftl index 42d720c68..9ec07cc46 100644 --- a/js/i18n/locales/zh-Hant/common.ftl +++ b/js/i18n/locales/zh-Hant/common.ftl @@ -76,3 +76,7 @@ pds-selector-handle-policy-checkbox = 我已閱讀並同意識別碼 ## Login login-show-live-on-bluesky = 在 Bluesky 上顯示我的直播狀態 login-show-live-on-bluesky-description = 直播時為您的 Bluesky 頭像加上紅色 LIVE 圓環,並允許 Streamplace 代您發布直播公告。取消勾選即可在不授予任何 Bluesky 帳號存取權限的情況下登入。 + +## Stream notifications +teleporting-in = 傳送倒數 +teleporting-to = 正在傳送至 @{ $handle } diff --git a/js/i18n/public/locales/en-US/common.json b/js/i18n/public/locales/en-US/common.json deleted file mode 100644 index 2deeccfed..000000000 --- a/js/i18n/public/locales/en-US/common.json +++ /dev/null @@ -1,388 +0,0 @@ -{ - "loading": "Loading...", - "error": "Error", - "cancel": "Cancel", - "confirm": "Confirm", - "close": "Close", - "open": "Open", - "ok": "OK", - "yes": "Yes", - "no": "No", - "continue": "Continue", - "back": "Back", - "next": "Next", - "finish": "Finish", - "save": "Save", - "delete": "Delete", - "edit": "Edit", - "create": "Create", - "update": "Update", - "refresh": "Refresh", - "success": "Success", - "warning": "Warning", - "info": "Information", - "content-warning-badge": "Intended for certain audiences", - "content-warning-title": "Heads up!", - "content-warning-description": "This may contain:", - "search-placeholder": "Search...", - "search-for-streamers": "Search for streamers...", - "message-input": "Enter your message...", - "please-log-in-to-access-this-page": "Please log in to access this page", - "go-to-settings": "Go to Settings", - "go-back": "Go Back", - "sign-in": "Sign In", - "log-in": "Log in", - "sign-up": "Sign up", - "go-home": "Go home", - "try-again": "Try again", - "completing-sign-in": "Completing sign-in…", - "already-logged-in": "You're already logged in.", - "signed-in-as": "Signed in as @{$handle}", - "signed-in-as-code": "Signed in as {$handle}", - "sign-in-description": "Sign in with your Atmosphere account, such as your {$network} account.", - "handle-label": "Handle", - "redirecting": "Redirecting…", - "sign-in-failed": "Sign-in failed", - "welcome-user": "Welcome, {$username}!", - "notification-count": "{$count ->\n [0] No notifications\n [1] One notification\n *[other] {$count} notifications\n }", - "user-offline": "user is offline", - "user-offline-message": "{$source ->\n [streamer] Looks like <1>@{$handle} is offline, but they recommend checking out:\n *[default] Looks like <1>@{$handle} is offline, but we recommend checking out:\n }", - "user-offline-no-recommendations": "Looks like <1>@{$handle} is offline right now.\nCheck back later.", - "streaming-title": "streaming {$title}", - "viewer-count": "{$count ->\n [0] 0 viewers\n [1] 1 viewer\n *[other] {$count} viewers\n }", - "pds-selector-title": "New to the Atmosphere?", - "pds-selector-description": "Choose where your Atmosphere account is hosted. You can use it with {$network}, Streamplace, and other Atmosphere apps.", - "pds-selector-show-other-hosts": "See {$count} other account hosts", - "pds-selector-custom-label": "Another account host", - "pds-selector-custom-description": "Enter your own account host URL", - "pds-selector-custom-url-label": "Account host URL", - "pds-selector-custom-url-placeholder": "https://pds.example.com", - "pds-selector-learn-more": "Learn more about self-hosting", - "pds-selector-info": "Each host has their own policies and reliability standards. Your account data lives on the host you choose, and you can migrate later.", - "pds-selector-different-policies": "Streamplace has separate moderation policies. You can be banned from Streamplace regardless of which account host you choose.", - "pds-selector-read-policies": "Read {$label}'s Terms of Service and Privacy Policy before continuing.", - "pds-selector-handle-policy-checkbox": "I have read and agree to the handle policy", - "creator-not-found-title": "Creator not found", - "creator-not-found-description": "We couldn't find an Atmosphere account for @{$handle}.", - "creator-lookup-failed-title": "We couldn't load this creator", - "creator-lookup-failed-description": "The Atmosphere directory isn't responding right now. Try again in a moment.", - "profile": "Profile", - "more-results": "More results", - "nav-home": "Home", - "nav-videos": "Videos", - "nav-following": "Following", - "nav-account": "Account", - "chat-no-messages": "No messages yet", - "chat-scroll-to-top": "Scroll to top", - "chat-scroll-to-bottom": "Scroll to bottom", - "chat-reply-to-message": "Reply to message", - "chat-pin-message": "Pin message", - "chat-unpin-message": "Unpin message", - "chat-dismiss-pinned": "Dismiss pinned message", - "chat-view-profile-bluesky": "View profile on {$network}", - "chat-send-message": "Send a message", - "chat-replying-to": "Replying to {$handle}", - "chat-insert-emoji": "Insert emoji", - "chat-skin-tone": "Skin tone", - "chat-send-button": "Chat", - "chat-failed-send": "Failed to send message", - "chat-log-in-to": "Log in to chat", - "chat-pop-out": "Pop out chat", - "chat-close": "Close chat", - "chat-open": "Open chat", - "chat-moderation-menu": "Message options", - "chat-pin-until-stream-end": "Until stream end", - "chat-pin-duration-minutes": "{$count} min", - "chat-hide-message": "Hide message", - "chat-block-user": "Block user", - "chat-delete-message": "Delete message", - "chat-delete-confirm": "Are you sure? Click again to confirm.", - "streamer-fallback": "Streamer", - "view-profile": "View profile", - "moderation-error-title": "Moderation action failed", - "moderation-message-pinned": "Comment pinned", - "moderation-user-blocked": "{$handle} has been blocked from this channel", - "moderation-hide-failed": "Failed to hide message", - "moderation-block-failed": "Failed to block user", - "moderation-pin-failed": "Failed to pin message", - "moderation-delete-failed": "Failed to delete message", - "moderators-title": "Moderators", - "moderators-add": "Add", - "moderators-adding": "Adding…", - "moderators-loading": "Loading moderators…", - "moderators-empty": "No moderators yet", - "moderators-empty-hint": "Add moderators to help manage your chat", - "moderators-add-title": "Add Moderator", - "moderators-add-description": "Enter the DID or handle of the user you want to add as a moderator and select their permissions.", - "moderators-handle-label": "Moderator DID or handle", - "moderators-handle-placeholder": "handle.bsky.social or did:plc:…", - "moderators-permissions-label": "Permissions", - "moderators-permission-ban": "Ban users", - "moderators-permission-ban-desc": "Block users from chat", - "moderators-permission-hide": "Hide messages", - "moderators-permission-hide-desc": "Hide individual chat messages", - "moderators-permission-manage": "Manage livestream", - "moderators-permission-manage-desc": "Update stream title", - "moderators-permission-pin": "Pin messages", - "moderators-permission-pin-desc": "Pin and unpin chat messages", - "moderators-added-toast": "Moderator added", - "moderators-add-failed": "Failed to add moderator", - "moderators-remove": "Remove", - "moderators-remove-title": "Remove moderator?", - "moderators-remove-description": "This will revoke all moderation permissions for {$handle}.", - "moderators-removed-toast": "Moderator removed", - "moderators-remove-failed": "Failed to remove moderator", - "moderators-expired": "Expired", - "moderators-expires": "Expires {$date}", - "moderators-error-required": "Please enter a DID or handle", - "moderators-error-permissions": "Please select at least one permission", - "moderators-cancel": "Cancel", - "chat-report-message": "Report message…", - "report-live-stream": "Report Live Stream", - "report-something-else": "Report Something Else", - "report-title": "Report", - "report-description": "Why are you submitting this report?", - "report-reason-spam": "Spam", - "report-reason-spam-desc": "Excessive unwanted promotion, replies, mentions", - "report-reason-violation": "Rule Violation", - "report-reason-violation-desc": "Direct, blatant violation of laws or terms of service", - "report-reason-misleading": "Misleading Content", - "report-reason-misleading-desc": "Misleading identity, affiliation, or content", - "report-reason-sexual": "Sexual Content", - "report-reason-sexual-desc": "Unwanted or mislabeled sexual content", - "report-reason-rude": "Harassment", - "report-reason-rude-desc": "Rude, harassing, explicit, or otherwise unwelcoming behavior", - "report-reason-other": "Other", - "report-reason-other-desc": "Reports not falling under another report category", - "report-comments-label": "Additional comments (optional)", - "report-comments-placeholder": "Provide additional context for this report…", - "report-submit": "Submit", - "report-submitting": "Submitting…", - "report-submitted": "Report submitted", - "report-failed": "Failed to submit report", - "badge-moderator": "Moderator", - "badge-streamer": "Streamer", - "badge-vip": "VIP", - "badge-bot": "Bot", - "badge-event": "Event", - "badge-fallback": "Badge", - "badge-issued-by-streamplace": "Issued by Streamplace", - "badge-issued-by": "Issued by {$issuer}", - "badge-issued": "Issued", - "badge-self-labeled": "Self-labeled", - "badge-details": "Badge details", - "badge-back-to-profile": "Back to profile", - "badge-previous": "Previous badge", - "badge-next": "Next badge", - "badge-count": "Badge {$current} of {$total}", - "stream-details": "Stream details", - "edit-title": "Edit title", - "edit-title-dialog-title": "Edit stream title", - "edit-title-label": "Stream title", - "edit-title-saving": "Saving…", - "edit-title-saved": "Stream title updated", - "edit-title-failed": "Failed to update stream title", - "activity-events": "Events", - "activity-just-chatting": "Just Chatting", - "activity-music": "Music", - "activity-art": "Art", - "activity-software-dev": "Software Dev", - "activity-cooking": "Cooking", - "activity-miniatures": "Miniatures", - "activity-makers-crafting": "Makers & Crafting", - "activity-fitness": "Fitness", - "activity-sports": "Sports", - "watching-count": "{$count} watching", - "follow": "Follow", - "share-copy": "Copy", - "share-copied": "Copied", - "share-copy-link": "Copy Link", - "share-copy-embed-code": "Copy Embed Code", - "share-copy-embed-url": "Copy Embed URL", - "share-copy-failed": "Couldn’t copy that", - "share-video": "Share", - "share-video-message": "Watch {$title} on Streamplace", - "share-to-bluesky": "Share to {$network}", - "share-more-options": "More sharing options", - "share-failed": "Couldn’t open sharing options", - "like-video": "Like", - "unlike-video": "Unlike", - "like-update-failed": "Couldn’t update like", - "like-state-restored": "Your previous like state was restored.", - "more-actions": "More actions", - "video-tools": "Video tools", - "video-embed": "Embed", - "more-details": "More details", - "less-details": "Less details", - "offline": "Offline", - "live-badge": "Live", - "player-buffering": "Buffering", - "reconnecting": "Reconnecting…", - "stream-may-resume": "The stream may resume shortly.", - "stream-offline": "Stream offline", - "user-not-streaming": "{$user} is not currently streaming", - "stream-is-offline-title": "Stream is offline", - "user-not-streaming-check-back": "{$user} is not currently streaming. Check back later.", - "back-to-home": "Back to home", - "download-video": "Download video", - "untitled": "Untitled", - "default-stream-title": "A livestream!", - "pip-enter": "Picture-in-picture", - "pip-exit": "Exit picture-in-picture", - "theatre-enter": "Theatre mode", - "theatre-exit": "Exit theatre mode", - "teleporting-in": "Teleporting in", - "teleporting-to": "Teleporting to @{$handle}", - "views-count": "{$count ->\n [one] {$count} view\n *[other] {$count} views\n }", - "likes-count": "{$count ->\n [one] {$count} like\n *[other] {$count} likes\n }", - "could-not-load-streams": "Could not load streams. You might be offline.", - "live-now-count": "{$count ->\n [one] {$count} person live now\n *[other] {$count} people live now\n }", - "no-one-streaming": "No one is streaming right now", - "check-back-later": "Check back later?", - "hero-title": "The video layer for everything.", - "hero-description": "Streamplace is a streaming platform built on the AT Protocol.", - "learn-more": "Learn more", - "videos-title": "Videos", - "no-videos-yet": "No videos yet.", - "could-not-load-videos": "Couldn't load videos: {$error}", - "no-results-for": "No results for \"{$query}\"", - "find-streamers": "Find streamers by name or handle", - "search": "Search", - "search-failed": "Search failed", - "something-went-wrong": "Something went wrong", - "unexpected-error": "An unexpected error occurred.", - "upload-add-tag": "Add tag, press Enter", - "upload-add-thumbnail": "Add thumbnail image", - "upload-cancel": "Cancel", - "upload-choose-file": "Choose a video file", - "upload-choose-file-btn": "Choose file", - "upload-content-warnings": "Content Warnings", - "upload-delete": "Delete", - "upload-deleting": "Deleting...", - "upload-description": "Description", - "upload-description-placeholder": "Describe your video...", - "upload-details": "Details", - "upload-file": "File", - "upload-license": "License", - "upload-optional-info": "Optional · JPG, PNG up to 975KB", - "upload-preparing": "Preparing upload...", - "upload-processing": "Processing video", - "upload-publish": "Publish", - "upload-published": "Published", - "upload-publishing": "Publishing...", - "upload-publishing-btn": "Publishing...", - "upload-ready-publish": "Ready to publish", - "upload-status": "Status", - "upload-tags": "Tags", - "upload-thumbnail": "Thumbnail", - "upload-thumbnail-preview": "Thumbnail preview", - "upload-title": "Title", - "upload-title-placeholder": "Give your video a title", - "upload-update-video": "Update Video", - "upload-updating": "Updating...", - "upload-view-video": "View video →", - "upload-waiting-process": "Waiting to process...", - "create-key-description-delete-if-exposed": "If you think your stream key has been exposed, delete it immediately and create a new key.", - "create-key-description-staff": "We will never, ever, ask for your key.", - "create-key-do-not-share": "DO NOT SHARE THIS KEY, including showing it on stream. Anyone in possession of your key may be able to stream from your account.", - "create-key-title": "Are you sure about creating a new stream key?", - "create-stream-key": "Create Stream Key", - "stream-key": "Stream Key", - "stream-key-help": "Your stream key identifies you to Streamplace. You can have more than one stream key at a time, and it's a good idea to prune old keys that you aren't using via the key manager.", - "stream-key-info-description": "For security reasons, you won't be able to view it again through your account. If you lose this stream key, you'll need to generate a new one.", - "stream-key-label": "Stream Key", - "stream-key-save-now": "Please save this stream key somewhere safe.", - "allow-everyone-archive": "Allow everyone to archive your content indefinitely", - "allow-everyone-distribute": "Allow everyone to distribute your content", - "allowed-broadcasters": "Allowed Broadcasters", - "allowed-broadcasters-help": "Enter the DIDs of the broadcasters you want to allow, one per line.", - "content-rights": "Content Rights", - "content-rights-help": "Optional copyright and license information for your stream.", - "content-warnings": "Content Warnings", - "content-warnings-help": "You're required to flag your stream with themes that viewers may want a heads-up about.", - "copied-to-clipboard": "Copied to clipboard", - "copy-link": "Copy link", - "copyright-notice": "Copyright Notice", - "copyright-year": "Copyright Year", - "credit-line": "Credit Line", - "custom-license": "Custom…", - "custom-license-url": "Custom License URL/Text", - "delete-after": "Delete After", - "delete-after-help": "Duration in seconds (e.g. 300 for 5 minutes).", - "distribution": "Distribution", - "distribution-help": "Control who can redistribute your stream and for how long archives are kept.", - "editing-video": "Editing video", - "failed-to-create-key": "Failed to create key", - "go-to-video": "Go to video", - "key-manager": "Key Manager", - "license": "License", - "login-to-manage-videos": "Please log in to manage your videos.", - "login-to-upload": "Please log in to upload videos.", - "metadata": "Metadata", - "metadata-learn-more": "Learn more about content metadata", - "metadata-save-failed": "Failed to save metadata", - "metadata-saved": "Metadata saved", - "moderation": "Moderation", - "moderation-help": "Add/remove stream moderators. Moderators can hide chat messages and time out users in your chat.", - "nav-dashboard": "Dashboard", - "nav-settings": "Settings", - "no-videos-yet-upload": "No videos yet.", - "optional": "Optional", - "required": "Required", - "select-license": "Select a license…", - "login-show-live-on-bluesky": "Show when I'm live on {$network}", - "login-show-live-on-bluesky-description": "Gives your {$network} avatar the red LIVE ring while you stream and lets Streamplace post announcements for you. Uncheck to sign in without granting any access to your {$network} account.", - "player-play": "Play", - "player-pause": "Pause", - "player-mute": "Mute", - "player-unmute": "Unmute", - "player-seek": "Seek", - "player-settings": "Settings", - "player-fullscreen": "Fullscreen", - "player-exit-fullscreen": "Exit fullscreen", - "player-theatre": "Theatre mode", - "player-exit-theatre": "Exit theatre mode", - "player-pip": "Picture-in-picture", - "player-exit-pip": "Exit picture-in-picture", - "player-danmu": "Danmu", - "player-enable-danmu": "Enable danmu", - "player-disable-danmu": "Disable danmu", - "player-quality": "Quality", - "player-latency": "Latency", - "player-quality-auto": "Auto", - "player-stats": "Player stats", - "player-stats-resolution": "Resolution", - "player-stats-viewport": "Viewport", - "player-stats-bitrate": "Bitrate", - "player-stats-skipped": "Skipped", - "player-stats-buffer": "Buffer", - "player-stats-codecs": "Codecs", - "player-stats-protocol": "Protocol", - "player-stats-latency-mode": "Latency Mode", - "player-stats-render-surface": "Render Surface", - "player-stats-session": "Session", - "player-protocol-webrtc": "WebRTC", - "player-protocol-hls": "HLS", - "player-latency-low": "Low Latency", - "player-latency-standard": "Standard", - "player-error-network": "Network error", - "player-error-playback": "Playback error", - "player-error-format": "Stream format not supported", - "player-error-stream-not-live": "Stream not live", - "player-error-network-retrying": "Network error. Retrying.", - "player-error-media-recovering": "Media error. Recovering.", - "player-error-hls-unsupported": "Your browser doesn't support HLS playback.", - "player-error-stream-unavailable": "Stream unavailable. Stopped reconnecting.", - "player-error-sdp-failed": "Failed to create SDP offer", - "player-error-ice-failed": "Failed to gather ICE candidates", - "player-error-negotiation-failed": "WebRTC negotiation failed", - "player-error-stream-stalled": "Stream stalled. Reconnecting.", - "player-error-connection-lost": "Connection lost. Reconnecting.", - "upload-error-format": "File doesn't appear to be a supported video format (MP4, WebM, MKV, MOV, AVI, OGG, FLV, MPEG-TS).", - "upload-error-timeout": "Processing timed out. Please try again later.", - "upload-error-processing": "Processing failed", - "upload-error-not-logged-in": "Not logged in", - "upload-title-untitled": "Untitled", - "emoji-skin-tone": "Skin tone", - "emoji-search": "Search emoji…" -} diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json deleted file mode 100644 index 1b664285a..000000000 --- a/js/i18n/public/locales/en-US/settings.json +++ /dev/null @@ -1,363 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "Download New Update", - "check-for-updates": "Check for Updates", - "bundled-runtype": "Bundled", - "ota-runtype": "Over-the-Air (OTA)", - "recovery-runtype": "Recovery Mode", - "modal-latest-version": "You are using the latest version.", - "modal-no-update-available": "You are on the latest version of Streamplace, hooray!", - "modal-update-available-title": "Update Available", - "modal-update-available-description": "A new version of Streamplace is ready to download", - "modal-update-failed": "Update check failed. You may need to update the app through the {$store}.", - "modal-update-failed-title": "Update Failed", - "modal-update-failed-description": "Update check failed. You may need to update the app through the {$store}.", - "button-reload-app-on-update": "Apply Update (will reload app)", - "use-custom-node": "Use Custom Node", - "default-url": "Default: {$url}", - "enter-custom-node-url": "Enter custom node URL", - "save-button": "Save", - "try-new-web": "Try the new web (beta)", - "try-new-web-description": "Switch to the new web interface. You can switch back anytime from this same setting.", - "try-old-app": "Use the original interface", - "try-old-app-description": "Go back to the original Streamplace interface.", - "language-selection": "Language", - "language-selection-description": "Choose your preferred language", - "input-search-languages": "Search languages...", - "help-translate": "Help us translate Streamplace", - "help-translate-description": "We're looking for volunteers to help translate Streamplace into more languages. If you're interested, please reach out to us on Discord or GitHub!", - "currently-translating": "Translations are on the way", - "currently-translating-description": "Some parts of the app may look incomplete. Thank you for your patience!", - "debug-recording-title": "Allow {$host} to record your livestream for debugging and improving the service", - "debug-recording-description": "Optional", - "livestream-recording-title": "Record my livestreams into VODs on {$host}", - "livestream-recording-description": "When enabled, your livestreams are saved so you can finalize and publish them as videos", - "auto-publish-vods-title": "Publish my livestreams as VODs automatically", - "auto-publish-vods-description": "When a recorded livestream ends, {$host} publishes it as a video right away, without waiting for you to finalize it", - "auto-publish-vods-stream-label": "Publish VOD automatically", - "auto-publish-vods-stream-description": "Publish recorded streams as VODs automatically. Applies to future streams too, until you turn it off.", - "manage-keys": "Manage Keys", - "settings-title": "Settings", - "error": "Error", - "about": "About", - "account": "Account", - "advanced": "Advanced", - "danmu": "Danmu", - "developer": "Developer", - "languages": "Languages", - "privacy-security": "Privacy & Security", - "streaming": "Streaming", - "notifications": "Notifications", - "notifications-title": "Push Notifications", - "notifications-web-description": "Get notified when streamers you follow go live.", - "notifications-mobile-description": "Push notifications are managed by your device settings.", - "notifications-blocked-description": "Blocked — you denied notification permission in your browser.", - "notifications-blocked-help": "To re-enable, update site permissions in your browser settings, then toggle this on again.", - "cancel": "Cancel", - "create": "Create", - "delete": "Delete", - "refresh": "Refresh", - "sign-in": "Sign In", - "update": "Update", - "log-out": "Log out", - "optional": "optional", - "account-greeting": "Hey, @{$handle}.", - "edit-profile-bluesky": "Edit profile (on {$network})", - "change-name-color": "Change name color", - "key-management": "Key Management", - "key-manager": "Key Manager", - "your-stream-pubkeys": "Your Stream Public Keys", - "no-keys": "No keys configured", - "pubkey-description": "Public keys are paired with stream keys (used in streaming software) to sign and verify your stream", - "keys-count": "{$count ->\n [one] {$count} key\n *[other] {$count} keys\n }", - "backup": "Backup", - "backup-enabled": "S3 Backup", - "backup-enabled-description": "Automatically backup recordings to S3-compatible storage", - "backup-connection-url": "Connection URL", - "backup-connection-url-placeholder": "Must be a valid S3 URL in the format s3+https://ACCESS_KEY:SECRET_KEY@region.endpoi.nt/bucket", - "backup-endpoint": "Endpoint", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "Bucket", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "Access Key", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "Secret Key", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "backup-region": "Region", - "backup-region-placeholder": "us-east-1", - "backup-test-connection": "Test Connection", - "backup-testing-connection": "Testing connection...", - "backup-connection-successful": "Connection successful", - "backup-connection-failed": "Failed to connect", - "show-password-in-url": "Show password in URL", - "show-password-in-url-description": "Display the secret key in the connection URL (if input)", - "requested-seconds-per-segment": "Seconds per segment", - "requested-seconds-per-segment-description": "Set the seconds per segment you wish to request the server use", - "recommendations": "Recommendations", - "manage-recommendations": "Manage Recommendations", - "recommendations-to-others": "Recommendations", - "recommendations-description": "Share up to 8 streamers you recommend to your viewers", - "no-recommendations-yet": "No recommendations configured yet", - "add-recommendation": "Add Recommendation", - "streamer-did": "Streamer DID", - "recommendations-count": "{$count ->\n [one] {$count} recommendation\n *[other] {$count} recommendations\n }", - "webhooks": "Webhooks", - "webhook-integrations": "Webhook Integrations", - "webhook-integrations-description": "Connect external services to receive real-time updates about your streams", - "create-webhook": "Create Webhook", - "edit-webhook": "Edit Webhook", - "delete-webhook": "Delete Webhook", - "no-webhooks-yet": "No webhooks configured yet", - "failed-load-webhooks": "Failed to load webhooks", - "webhook-will-no-longer-receive-events": "This webhook will no longer receive events", - "create-first-webhook-description": "Create your first webhook to start receiving stream events", - "example-captain-hook": "Captain Hook", - "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", - "activates-on": "Activates on:", - "events": "Events", - "events-livestream": "Livestream Events", - "events-chat": "Chat Events", - "events-stream-received": "Stream Received Events", - "untitled-webhook": "Untitled Webhook", - "inactive": "Inactive", - "active": "Active", - "multistream": "Multistreaming", - "multistream-targets": "Multistream Targets", - "multistream-description": "Automatically push your Streamplace livestreams to other streaming services like Twitch or YouTube.", - "create-multistream-target": "Create Multistream Target", - "untitled-multistream-target": "Untitled Target", - "failed-load-multistream-targets": "Failed to load multistream targets. Please try again.", - "failed-toggle-multistream-target": "Failed to toggle multistream target. Please try again.", - "failed-delete-multistream-target": "Failed to delete multistream target. Please try again.", - "no-multistream-targets-yet": "No targets yet!", - "no-multistream-destinations": "No multistream destinations yet", - "multistream-targets-count": "{$count ->\n [one] {$count} target\n *[other] {$count} targets\n }", - "multistream-delete-target-confirmation": "Are you sure you want to delete \"{$target}\"?", - "this-action-cannot-be-undone": "This action cannot be undone.", - "rtmp-target-name": "RTMP Target", - "rtmp-target-url": "RTMP URL", - "rtmp-target-name-placeholder": "My Multistream Target", - "multistream-create-target": "Create Target", - "multistream-edit-target": "Edit Target", - "created": "created", - "status": "status", - "debug-recording": "Debug Recording", - "danmu-enabled": "Enable Danmu", - "danmu-enabled-description": "Display live chat messages as floating comments on your screen", - "danmu-opacity": "Opacity", - "danmu-speed": "Speed", - "danmu-lane-count": "Lane Count", - "danmu-max-messages": "Max Messages", - "app-version-description": "No updates are available at this time", - "confirm-delete": "Are you sure you want to delete this?", - "action-cannot-be-undone": "This action cannot be undone", - "name-optional": "Name (optional)", - "deleting": "Deleting...", - "saving": "Saving...", - "go-to-dashboard": "Go to Dashboard", - "need-setup-live-dashboard": "Need to set up streaming first? Visit the live dashboard", - "no-languages-found": "No languages found", - "branding": "Branding", - "branding-admin": "Branding Administration", - "branding-admin-description": "Customize your Streamplace instance. Note that settings may take a few hours to propagate.", - "branding-login-required": "Please log in to manage branding", - "branding-configuration": "Configuration", - "branding-text-settings": "Text Settings", - "branding-colors": "Colors", - "branding-legal-links": "Legal Links", - "branding-bundle": "Export and import", - "branding-bundle-description": "A bundle is a zip with branding.yaml (everything set here, with the defaults listed for reference) plus the image files. Edit the YAML by hand or import it on another node.", - "branding-bundle-export": "Download bundle", - "branding-bundle-import": "Import bundle…", - "branding-bundle-merge": "Merge: keep settings the bundle doesn't mention (otherwise they are removed)", - "branding-bundle-preview": "Changes from {$name}", - "branding-bundle-no-changes": "Nothing would change.", - "branding-bundle-apply": "Apply", - "branding-bundle-imported": "Branding imported ({$count} keys)", - "branding-bundle-import-failed": "Import failed", - "branding-bundle-export-failed": "Export failed", - "branding-bundle-invalid": "That bundle can't be imported", - "branding-navigation": "Navigation", - "branding-chat-layout": "Chat message layout", - "branding-chat-layout-description": "Compact is one line per message with the time and handle. Avatar shows each message as a post: avatar, display name, handle and age above the text.", - "branding-chat-compact": "Compact", - "branding-chat-avatar": "Avatar", - "branding-chat-name-colors": "Chat name colors", - "branding-chat-name-colors-description": "Show display names in each user's chosen chat color, or in the default text color.", - "branding-chat-name-colors-on": "User colors", - "branding-chat-name-colors-off": "Default color", - "branding-chat-badges": "Chat badges", - "branding-chat-badges-description": "Badges beside names in chat. All shows everything; Custom only shows badges issued from your badge definitions and hides the built-in streamer, moderator and bot marks; None hides every badge. Verified checks are separate.", - "branding-chat-badges-all": "All", - "branding-chat-badges-custom": "Custom only", - "branding-chat-badges-none": "None", - "branding-nav-links": "Navigation links", - "branding-nav-links-description": "Replace the sidebar's browse and creator sections with your own links: a JSON list of objects with label, url and an optional icon (home, search, play, live, bell, message, hash, list, bookmark, user, settings, video, book). Paths starting with / navigate inside the app; other URLs open as links.", - "branding-nav-cta": "Navigation button", - "branding-nav-cta-description": "A highlighted button under the navigation links: JSON with label and url.", - "branding-app-banner": "Mobile app suggestion", - "branding-app-banner-description": "On phones, the web app suggests the Streamplace mobile app: a banner in the page and, on iOS, the App Store's Smart App Banner. Turn it off for a node that is its own product.", - "branding-app-banner-on": "Suggest the app", - "branding-app-banner-off": "Off", - "branding-social-links": "Social links", - "branding-social-links-description": "The row of icon links at the bottom of the sidebar. The first field is its heading; the second is a JSON list of objects with label, url and icon, where icon is bluesky, discord, one of the navigation icon names, or socialIcon1 to socialIcon4 for an uploaded icon. Unset shows the Streamplace links; Hide removes the row.", - "branding-social-heading-placeholder": "Say Hello?", - "branding-social-hide": "Hide", - "branding-upload": "Upload", - "branding-remove": "Remove", - "branding-social-icons-description": "Uploaded icons, referenced from the list above by slot name. SVG preferred, 24 x 24, max 64KB; an SVG drawn with currentColor takes the sidebar's icon color.", - "branding-theme": "Theme", - "branding-theme-description": "Page background and text color, one pair per color scheme; the app derives its raised surfaces, muted text and hairline borders from them. Hex colors (#rrggbb). Accent and status colors are separate settings above. Leave a value unset to use the default, or set only the background and the text color is chosen to read on it.", - "branding-background-dark": "Page background (dark mode)", - "branding-foreground-dark": "Text color (dark mode)", - "branding-background-light": "Page background (light mode)", - "branding-foreground-light": "Text color (light mode)", - "branding-reset": "Reset", - "branding-default": "default", - "branding-status-colors": "Status Colors", - "branding-status-colors-description": "Colors for errors, success, warnings, informational notes and the live badge. Hex colors (#rrggbb); text on top of them is chosen automatically.", - "branding-danger-color": "Danger / error", - "branding-success-color": "Success", - "branding-warning-color": "Warning", - "branding-info-color": "Info", - "branding-live-color": "Live badge", - "branding-images": "Images", - "branding-broadcaster-did": "Broadcaster DID", - "branding-broadcaster-did-description": "Leave empty to use server default", - "branding-site-title": "Site Title", - "branding-site-title-placeholder": "Enter new site title", - "branding-site-description": "Site Description", - "branding-site-description-placeholder": "Enter site description", - "branding-link-cards": "Link cards", - "branding-link-cards-description": "What a shared link shows for each page: the stream page, the front page (live tab), the videos tab, a video page and the go-live tab. Templates take {} (the site name below, or the site title), and on pages about one account {} (their display name, else handle) and {}. Descriptions of stream and video pages are their post text.", - "branding-network-name": "Network name", - "branding-network-name-description": "What the app calls the social network viewers sign in with and share to: \"Sign in with your {$network} account\", \"Share to {$network}\", the live-ring permission. Default Bluesky.", - "branding-network-profile-url": "Profile link", - "branding-network-profile-url-description": "Where a chat user's profile link goes, from the profile card and the message menu. Use {} and {} as placeholders. Default is the Bluesky profile page.", - "branding-network-icon-description": "Icon shown on those profile links. SVG preferred, max 64KB; unset shows the Bluesky butterfly.", - "branding-verified-icon-description": "Badge shown beside verified users. SVG preferred, max 64KB, currentColor takes the primary color; unset shows a check in the primary color.", - "branding-login-placeholder": "Login handle example", - "branding-login-placeholder-description": "The example handle shown in the login form's empty handle field, e.g. you.example.com.", - "branding-default-streamer": "Default Streamer", - "branding-default-streamer-none": "None", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-default-video": "Default video", - "branding-default-video-description": "While set, the front page shows this video instead of the default streamer's stream: the replay to send everyone to after an event. Its at:// URI, or its page path (handle/video/rkey). Clear it to return the front page to the streamer.", - "branding-default-video-placeholder": "at://did:plc:.../place.stream.video/... or handle/video/rkey", - "branding-clear-default-video": "Clear default video", - "branding-clear-default-streamer": "Clear Default Streamer", - "branding-primary-color": "Primary Color", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Accent Color (secondary surfaces, highlights)", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "Main Logo", - "branding-main-logo-description": "SVG, PNG, or JPEG (max 500KB)", - "branding-favicon": "Default favicon", - "branding-favicon-light": "Light mode favicon", - "branding-favicon-dark": "Dark mode favicon", - "branding-favicon-description": "SVG, PNG, or ICO (max 100KB). Used when an override is absent or the client does not support theme selection. An icon with a background works well as the default.", - "branding-favicon-override-description": "SVG, PNG, or ICO (max 100KB). Overrides the default for the browser or OS appearance, not the app theme. Transparent backgrounds are supported.", - "branding-sidebar-bg": "Sidebar Background Image", - "branding-sidebar-bg-description": "SVG, PNG, or JPEG (max 500kb) - appears aligned to bottom of sidebar, full width. Upload an image with opacity for best results, as there is not currently a separate opacity option.", - "branding-current": "Current: {$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "Upload Logo", - "branding-delete-logo": "Delete Logo", - "branding-upload-favicon": "Upload Favicon", - "branding-delete-favicon": "Delete Favicon", - "branding-upload-background": "Upload Background", - "branding-link-banner": "Link Preview Image", - "branding-link-banner-description": "The image shown when the site's link is shared (OpenGraph / Twitter card). PNG, JPEG or WebP, 1200 x 630 recommended, max 2MB. The card's title and description come from the Site Title and Site Description above.", - "branding-upload-link-banner": "Upload Image", - "branding-delete-link-banner": "Remove Image", - "branding-delete-background": "Delete Background", - "branding-web-only": "Image uploads are only available on web.", - "refresh-branding": "Refresh branding assets", - "branding-add-legal-link": "Add Legal Link", - "branding-edit-legal-link": "Edit Legal Link", - "branding-legal-link-text-placeholder": "Link text (e.g., Privacy Policy)", - "branding-legal-link-url-placeholder": "URL (e.g., https://example.com/privacy)", - "add": "Add", - "edit": "Edit", - "branding-not-authenticated": "Please log in first", - "branding-empty-value": "Please enter a value", - "branding-update-success": "{$key} updated successfully", - "branding-upload-success": "{$key} uploaded successfully", - "branding-delete-success": "{$key} deleted successfully", - "branding-upload-failed": "Failed to upload", - "branding-delete-failed": "Failed to delete", - "branding-not-available": "File uploads are only available on web", - "node-legal-documents": "Broadcaster-specific Documents", - "backup-save": "Save backup settings", - "backup-saving": "Saving backup settings...", - "backup-secret-key-set-placeholder": "(Password already set)", - "backup-error-invalid-endpoint": "Must be a valid domain name", - "backup-error-invalid-bucket": "Must contain only lowercase letters, numbers, dots, and hyphens", - "backup-error-invalid-segment-duration": "Must be between 1 and 60 seconds", - "backup-error-load-failed": "Failed to load storage settings", - "backup-error-update-failed": "Failed to update backup status", - "backup-error-save-failed": "Failed to save storage settings", - "backup-error-missing-secret": "Cannot update S3 settings without the secret key. Please re-enter it.", - "backup-segment-duration-placeholder": "6", - "badges": "Badges", - "badges-cosmetic-section": "Cosmetic Badges", - "badges-empty-state": "You haven't been issued any badges yet.", - "badges-failed-load": "Failed to load badges", - "badges-failed-update": "Failed to update badge selection", - "badges-issued-by": "Issued by {$issuer}", - "badges-streamer-section": "Streamer Badges", - "issue-badges": "Issue Badges", - "issue-badges-back-to-definitions": "Badge Definitions", - "issue-badges-badge-name": "Badge Name", - "issue-badges-badge-name-placeholder": "e.g. VIP, Supporter", - "issue-badges-badge-type": "Badge Type", - "issue-badges-choose-image": "Choose Image", - "issue-badges-create-definition": "Create Badge Definition", - "issue-badges-create-definition-description": "Define a new badge type that can be issued to viewers", - "issue-badges-create-definition-subtitle": "Define a new badge type", - "issue-badges-definition-created": "Badge definition created", - "issue-badges-description-optional": "Description (optional)", - "issue-badges-description-placeholder": "Optional description for this badge", - "issue-badges-failed-create": "Failed to create badge definition", - "issue-badges-failed-issue": "Failed to issue badge", - "issue-badges-failed-load": "Failed to load badge definitions", - "issue-badges-image-optional": "Image (optional)", - "issue-badges-image-too-large": "Image must be under 256KB", - "issue-badges-image-web-only": "Image upload is only available on web", - "issue-badges-issue-badge": "Issue Badge", - "issue-badges-issue-badge-description": "Issue the \"{$name}\" badge to a viewer by their DID", - "issue-badges-issued": "Badge issued", - "issue-badges-issued-to": "Issued to {$did}", - "issue-badges-manage-description": "Create badge definitions and issue them to viewers", - "issue-badges-recipient-did": "Recipient DID", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "Tap to issue", - "issue-badges-your-definitions": "Your Badge Definitions", - "chat-profile": "Chat Profile", - "chat-profile-login-required": "Please log in to edit your chat profile.", - "chat-profile-saved": "Chat profile saved", - "chat-profile-save-failed": "Failed to save chat profile", - "chat-profile-name-color": "Name Color", - "chat-profile-name-color-description": "Choose the color of your name in chat.", - "chat-profile-preview-message": "Hello, world!", - "chat-profile-self-labels": "Self Labels", - "chat-profile-label-bot": "Bot", - "chat-profile-label-bot-description": "Mark your chat messages as coming from a bot.", - "control-panel": "Control Panel", - "create-stream-key-in-stream-settings": "Create a stream key in Stream Settings", - "creator-settings": "Creator Settings", - "delete-all-keys": "Delete All Keys", - "delete-all-keys-btn": "Delete All", - "delete-all-keys-description": "This will permanently remove all {$count} stream keys. Any streaming software configured with these keys will stop working.", - "delete-all-keys-title": "Delete all stream keys?", - "failed-delete-keys": "Failed to delete {$count} key(s)", - "failed-to-delete-key": "Failed to delete key", - "my-videos": "My Videos", - "stream-settings": "Stream Settings", - "stream-settings-link": "Stream Settings", - "time-ago-days": "{$days}d ago", - "time-ago-hours": "{$hours}h ago", - "time-ago-minutes": "{$minutes}m ago", - "time-ago-seconds": "{$seconds}s ago", - "upload-videos": "Upload Videos" -} diff --git a/js/i18n/public/locales/es-ES/common.json b/js/i18n/public/locales/es-ES/common.json deleted file mode 100644 index c4448a6fc..000000000 --- a/js/i18n/public/locales/es-ES/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "Cargando...", - "error": "Error", - "cancel": "Cancelar", - "confirm": "Confirmar", - "close": "Cerrar", - "open": "Abrir", - "ok": "OK", - "yes": "Sí", - "no": "No", - "continue": "Continuar", - "back": "Atrás", - "next": "Siguiente", - "finish": "Finalizar", - "save": "Guardar", - "delete": "Eliminar", - "edit": "Editar", - "create": "Crear", - "update": "Actualizar", - "refresh": "Actualizar", - "success": "Éxito", - "warning": "Advertencia", - "info": "Información", - "search-placeholder": "Buscar...", - "message-input": "Escribe tu mensaje...", - "please-log-in-to-access-this-page": "Por favor, inicia sesión para acceder a esta página", - "go-to-settings": "Ir a Configuración", - "go-back": "Volver", - "welcome-user": "¡Bienvenido, {$username}!", - "notification-count": "{$count ->\n [0] Sin notificaciones\n [1] Una notificación\n *[other] {$count} notificaciones\n }", - "user-offline": "usuario desconectado", - "user-offline-message": "{$source ->\n [streamer] Parece que <1>@{$handle} está desconectado, pero ellos recomiendan ver:\n *[default] Parece que <1>@{$handle} está desconectado, pero te recomendamos ver:\n }", - "user-offline-no-recommendations": "Parece que <1>@{$handle} está desconectado ahora mismo.\nVuelve más tarde.", - "streaming-title": "transmitiendo {$title}", - "viewer-count": "{$count ->\n [0] 0 espectadores\n [1] 1 espectador\n *[other] {$count} espectadores\n }", - "pds-selector-title": "¿Nuevo en Atmosphere?", - "pds-selector-description": "Necesitarás seleccionar un PDS (Servidor de Datos Personal) para acceder a apps en Atmosphere, como Bluesky, Tangled y Spark.", - "pds-selector-custom-label": "Otro PDS", - "pds-selector-custom-description": "Introduce la URL de tu propio servidor PDS", - "pds-selector-custom-url-label": "URL de PDS personalizado", - "pds-selector-custom-url-placeholder": "https://pds.ejemplo.com", - "pds-selector-learn-more": "Más información sobre el autoalojamiento", - "pds-selector-info": "Cada servidor tiene sus propias políticas y estándares de fiabilidad. Tus datos ATProto viven en el servidor que elijas y puedes migrar más tarde. Nota: Streamplace tiene sus propias reglas de moderación; puedes ser expulsado de Streamplace independientemente del servidor que elijas.", - "pds-selector-read-policies": "Lee los Términos de Servicio y la Política de Privacidad de {$label} antes de continuar.", - "pds-selector-handle-policy-checkbox": "He leído y acepto la política de identificadores", - "login-show-live-on-bluesky": "Mostrar cuando estoy en directo en Bluesky", - "login-show-live-on-bluesky-description": "Añade el anillo rojo LIVE a tu avatar de Bluesky mientras transmites y permite que Streamplace publique anuncios por ti. Desmárcalo para iniciar sesión sin conceder ningún acceso a tu cuenta de Bluesky." -} diff --git a/js/i18n/public/locales/es-ES/settings.json b/js/i18n/public/locales/es-ES/settings.json deleted file mode 100644 index cda42b357..000000000 --- a/js/i18n/public/locales/es-ES/settings.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "Descargar Nueva Actualización", - "check-for-updates": "Buscar Actualizaciones", - "bundled-runtype": "Empaquetado", - "ota-runtype": "Over-the-Air (OTA)", - "recovery-runtype": "Modo de Recuperación", - "modal-latest-version": "Estás usando la última versión.", - "modal-no-update-available": "¡Tienes la versión más reciente de Streamplace, genial!", - "modal-update-available-title": "Actualización Disponible", - "modal-update-available-description": "Una nueva versión de Streamplace está lista para descargar", - "modal-update-failed": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar\nla aplicación a través de {$store}.", - "modal-update-failed-title": "Actualización Falló", - "modal-update-failed-description": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar\nla aplicación a través de {$store}.", - "button-reload-app-on-update": "Aplicar Actualización (recargará la aplicación)", - "use-custom-node": "Usar Nodo Personalizado", - "default-url": "Predeterminado: {$url}", - "enter-custom-node-url": "Introduce la URL del nodo personalizado", - "save-button": "Guardar", - "try-new-web": "Probar la nueva web (beta)", - "try-new-web-description": "Cambia a la nueva interfaz web. Puedes volver en cualquier momento desde este mismo ajuste.", - "try-old-app": "Usar la interfaz original", - "try-old-app-description": "Vuelve a la interfaz original de Streamplace.", - "language-selection": "Idioma", - "language-selection-description": "Elige tu idioma preferido", - "help-translate": "Ayúdanos a traducir Streamplace", - "help-translate-description": "Buscamos voluntarios para ayudar a traducir Streamplace a más idiomas. Si te interesa, contáctanos en Discord o GitHub!", - "currently-translating": "Las traducciones están en camino", - "currently-translating-description": "Algunas partes de la aplicación pueden verse incompletas. ¡Gracias por tu paciencia!", - "debug-recording-title": "Permitir que {$host} grabe tu retransmisión en directo para depuración y mejora del servicio", - "debug-recording-description": "Opcional", - "input-search-languages": "Buscar idiomas...", - "manage-keys": "Gestionar Claves", - "settings-title": "Configuración", - "loading": "Cargando...", - "error": "Error", - "cancel": "Cancelar", - "confirm": "Confirmar", - "welcome-user": "¡Bienvenido, {$username}!", - "notification-count": "{$count ->\n [0] Sin notificaciones\n [1] Una notificación\n *[other] {$count} notificaciones\n }", - "search-placeholder": "Buscar...", - "message-input": "Introduce tu mensaje...", - "success": "Éxito", - "warning": "Aviso", - "info": "Información", - "close": "Cerrar", - "open": "Abrir", - "delete": "Eliminar", - "edit": "Editar", - "create": "Crear", - "update": "Actualizar", - "refresh": "Actualizar", - "save": "Guardar", - "cancel-button": "Cancelar", - "ok": "Aceptar", - "yes": "Sí", - "no": "No", - "continue": "Continuar", - "back": "Volver", - "next": "Siguiente", - "finish": "Finalizar", - "about": "Acerca de", - "account": "Cuenta", - "advanced": "Avanzado", - "danmu": "Danmu", - "developer": "Desarrollador", - "languages": "Idiomas", - "privacy-security": "Privacidad y Seguridad", - "streaming": "Transmisión", - "notifications": "Notificaciones", - "sign-in": "Iniciar Sesión", - "log-out": "Cerrar sesión", - "account-greeting": "Hola, @{$handle}.", - "edit-profile-bluesky": "Editar perfil (en Bluesky)", - "change-name-color": "Cambiar color de nombre", - "key-management": "Gestión de Claves", - "key-manager": "Gestor de Claves", - "your-stream-pubkeys": "Tus Claves Públicas de Transmisión", - "no-keys": "No hay claves configuradas", - "pubkey-description": "Las claves públicas se emparejan con claves de transmisión (usadas en software de transmitiendo) para firmar y verificar tu transmisión", - "keys-count": "{$count ->\n [one] {$count} clave\n [many] {$count} claves\n *[other] {$count} claves\n }", - "webhooks": "Webhooks", - "webhook-integrations": "Integraciones de Webhook", - "webhook-integrations-description": "Conecta servicios externos para recibir actualizaciones en tiempo real sobre tus transmisiones", - "create-webhook": "Crear Webhook", - "edit-webhook": "Editar Webhook", - "delete-webhook": "Eliminar Webhook", - "no-webhooks-yet": "Aún no hay webhooks configurados", - "failed-load-webhooks": "Error al cargar webhooks", - "webhook-will-no-longer-receive-events": "Este webhook ya no recibirá eventos", - "create-first-webhook-description": "Crea tu primer webhook para empezar a recibir eventos de transmisión", - "example-captain-hook": "Capitán Garfio", - "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", - "activates-on": "Activa en:", - "events-livestream": "Eventos de Transmisión en Directo", - "events-chat": "Eventos de Chat", - "untitled-webhook": "Webhook Sin Título", - "inactive": "Inactivo", - "debug-recording": "Grabación de Depuración", - "danmu-enabled": "Habilitar Danmu", - "danmu-enabled-description": "Muestra mensajes de chat en vivo como comentarios flotantes en tu pantalla", - "danmu-opacity": "Opacidad", - "danmu-speed": "Velocidad", - "danmu-lane-count": "Número de Carriles", - "danmu-max-messages": "Mensajes Máximos", - "app-version-description": "Información de la versión actual", - "confirm-delete": "¿Estás seguro de que quieres eliminar esto?", - "action-cannot-be-undone": "Esta acción no se puede deshacer", - "name-optional": "Nombre (opcional)", - "deleting": "Eliminando...", - "saving": "Guardando...", - "go-to-dashboard": "Ir al Panel", - "need-setup-live-dashboard": "Primero necesitas configurar una transmisión en directo en el panel", - "no-languages-found": "No se encontraron idiomas", - "backup-save": "Guardar ajustes de copia de seguridad", - "backup-saving": "Guardando ajustes de copia de seguridad...", - "backup-secret-key-set-placeholder": "(Contraseña ya establecida)", - "backup-error-invalid-endpoint": "Debe ser un nombre de dominio válido", - "backup-error-invalid-bucket": "Debe contener solo letras minúsculas, números, puntos y guiones", - "backup-error-invalid-segment-duration": "Debe estar entre 1 y 60 segundos", - "backup-error-load-failed": "Error al cargar los ajustes de almacenamiento", - "backup-error-update-failed": "Error al actualizar el estado de la copia de seguridad", - "backup-error-save-failed": "Error al guardar los ajustes de almacenamiento", - "backup-error-missing-secret": "No se pueden actualizar los ajustes de S3 sin la clave secreta. Por favor, vuelva a introducirla.", - "backup-segment-duration-placeholder": "6", - "backup": "Copia de seguridad", - "backup-enabled": "S3 Backup", - "backup-enabled-description": "Respaldar automáticamente las grabaciones en almacenamiento compatible con S3", - "backup-connection-url": "URL de conexión", - "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", - "backup-endpoint": "Punto de conexión", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "Contenedor", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "Clave de acceso", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "Clave secreta", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "show-password-in-url": "Mostrar contraseña en la URL", - "show-password-in-url-description": "Mostrar la clave secreta en la URL de conexión (si se introduce)", - "recommendations-to-others": "Recomendaciones a otros", - "recommendations-description": "Comparte hasta 8 streamers que recomiendas a tus espectadores", - "no-recommendations-yet": "No hay recomendaciones configuradas todavía", - "multistream": "Multi-transmisión", - "multistream-targets": "Objetivos de multi-transmisión", - "multistream-description": "Envía automáticamente tus transmisiones en directo de Streamplace a otros servicios de streaming como Twitch o YouTube.", - "create-multistream-target": "Crear objetivo de multi-transmisión", - "untitled-multistream-target": "Objetivo sin título", - "failed-load-multistream-targets": "Error al cargar los objetivos de multi-transmisión. Por favor, inténtalo de nuevo.", - "failed-toggle-multistream-target": "Error al cambiar el estado del objetivo de multi-transmisión. Por favor, inténtalo de nuevo.", - "no-multistream-targets-yet": "¡Aún no hay objetivos!", - "multistream-targets-count": "{$count ->\n [one] {$count} objetivo\n [many] {$count} objetivos\n *[other] {$count} objetivos\n }", - "multistream-delete-target-confirmation": "¿Estás seguro de que quieres eliminar \"{$target}\"?", - "this-action-cannot-be-undone": "Esta acción no se puede deshacer.", - "rtmp-target-name": "Objetivo RTMP", - "rtmp-target-url": "URL de RTMP", - "rtmp-target-name-placeholder": "Mi objetivo de multi-transmisión", - "multistream-create-target": "Crear objetivo", - "multistream-edit-target": "Editar objetivo", - "created": "creado", - "status": "estado", - "branding": "Branding", - "branding-admin": "Administración de branding", - "branding-admin-description": "Personaliza tu instancia de Streamplace. Ten en cuenta que la configuración puede tardar varias horas en propagarse.", - "branding-login-required": "Por favor, inicia sesión para gestionar el branding", - "branding-configuration": "Configuración", - "branding-text-settings": "Configuración de texto", - "branding-colors": "Colores", - "branding-legal-links": "Vínculos legales", - "branding-images": "Imágenes", - "branding-broadcaster-did": "DID del radiodifusor", - "branding-broadcaster-did-description": "Dejar vacío para usar el valor predeterminado del servidor", - "branding-site-title": "Título del sitio", - "branding-site-title-placeholder": "Introduce un nuevo título de sitio", - "branding-site-description": "Descripción del sitio", - "branding-site-description-placeholder": "Introduce la descripción del sitio", - "branding-default-streamer": "Streamer predeterminado", - "branding-default-streamer-none": "Ninguno", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "Quitar streamer predeterminado", - "branding-primary-color": "Color principal", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Color de acento", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "Logo principal", - "branding-main-logo-description": "SVG, PNG o JPEG (máx. 500KB)", - "branding-favicon": "Favicon", - "branding-favicon-description": "SVG, PNG o ICO (máx. 100KB)", - "branding-sidebar-bg": "Imagen de fondo de la barra lateral", - "branding-sidebar-bg-description": "SVG, PNG o JPEG (máx. 500kb) - aparece alineada en la parte inferior de la barra lateral, a todo el ancho. Sube una imagen con opacidad para mejores resultados, ya que actualmente no hay una opción de opacidad separada.", - "branding-current": "Actual: {$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "Subir logo", - "branding-delete-logo": "Eliminar logo", - "branding-upload-favicon": "Subir favicon", - "branding-delete-favicon": "Eliminar favicon", - "branding-upload-background": "Subir imagen de fondo", - "branding-delete-background": "Eliminar imagen de fondo", - "branding-web-only": "La subida de imágenes solo está disponible en la web.", - "refresh-branding": "Actualizar recursos de branding", - "branding-add-legal-link": "Agregar vínculo legal", - "branding-edit-legal-link": "Editar vínculo legal", - "branding-legal-link-text-placeholder": "Texto del vínculo (ej., Política de Privacidad)", - "branding-legal-link-url-placeholder": "URL (ej., https://ejemplo.com/privacidad)", - "add": "Agregar", - "active": "Activo", - "optional": "opcional", - "branding-not-authenticated": "Por favor, inicia sesión primero", - "branding-empty-value": "Por favor, introduce un valor", - "branding-update-success": "{$key} actualizado correctamente", - "branding-upload-success": "{$key} subido correctamente", - "branding-delete-success": "{$key} eliminado correctamente", - "branding-upload-failed": "Error al subir", - "branding-delete-failed": "Error al eliminar", - "branding-not-available": "La subida de archivos solo está disponible en la web", - "node-legal-documents": "Documentos específicos del radiodifusor", - "badges": "Insignias", - "badges-cosmetic-section": "Insignias cosméticas", - "badges-empty-state": "Aún no te han otorgado ninguna insignia.", - "badges-failed-load": "Error al cargar las insignias", - "badges-failed-update": "Error al actualizar la selección de insignias", - "badges-issued-by": "Otorgada por {$issuer}", - "badges-streamer-section": "Insignias del streamer", - "issue-badges": "Emitir insignias", - "issue-badges-back-to-definitions": "Definiciones de insignias", - "issue-badges-badge-name": "Nombre de la insignia", - "issue-badges-badge-name-placeholder": "ej. VIP, Colaborador", - "issue-badges-badge-type": "Tipo de insignia", - "issue-badges-choose-image": "Elegir imagen", - "issue-badges-create-definition": "Crear definición de insignia", - "issue-badges-create-definition-description": "Define un nuevo tipo de insignia que se puede otorgar a los espectadores", - "issue-badges-create-definition-subtitle": "Define un nuevo tipo de insignia", - "issue-badges-definition-created": "Definición de insignia creada", - "issue-badges-description-optional": "Descripción (opcional)", - "issue-badges-description-placeholder": "Descripción opcional para esta insignia", - "issue-badges-failed-create": "Error al crear la definición de insignia", - "issue-badges-failed-issue": "Error al emitir la insignia", - "issue-badges-failed-load": "Error al cargar las definiciones de insignias", - "issue-badges-image-optional": "Imagen (opcional)", - "issue-badges-image-too-large": "La imagen debe ser menor de 256KB", - "issue-badges-image-web-only": "La subida de imágenes solo está disponible en la web", - "issue-badges-issue-badge": "Emitir insignia", - "issue-badges-issue-badge-description": "Emite la insignia \"{$name}\" a un espectador mediante su DID", - "issue-badges-issued": "Insignia emitida", - "issue-badges-issued-to": "Emitida a {$did}", - "issue-badges-manage-description": "Crea definiciones de insignias y emítelas a los espectadores", - "issue-badges-recipient-did": "DID del destinatario", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "Toca para emitir", - "issue-badges-your-definitions": "Tus definiciones de insignias" -} diff --git a/js/i18n/public/locales/fr-FR/common.json b/js/i18n/public/locales/fr-FR/common.json deleted file mode 100644 index 1a6809ae6..000000000 --- a/js/i18n/public/locales/fr-FR/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "Chargement...", - "error": "Erreur", - "cancel": "Annuler", - "confirm": "Confirmer", - "close": "Fermer", - "open": "Ouvrir", - "ok": "OK", - "yes": "Oui", - "no": "Non", - "continue": "Continuer", - "back": "Retour", - "next": "Suivant", - "finish": "Terminer", - "save": "Enregistrer", - "delete": "Supprimer", - "edit": "Modifier", - "create": "Créer", - "update": "Mettre à jour", - "refresh": "Actualiser", - "success": "Succès", - "warning": "Avertissement", - "info": "Information", - "search-placeholder": "Rechercher...", - "message-input": "Entrez votre message...", - "please-log-in-to-access-this-page": "Veuillez vous connecter pour accéder à cette page", - "go-to-settings": "Aller aux Paramètres", - "go-back": "Retour", - "welcome-user": "Bienvenue, {$username} !", - "notification-count": "{$count ->\n [0] Aucune notification\n [1] Une notification\n *[other] {$count} notifications\n }", - "user-offline": "utilisateur hors ligne", - "user-offline-message": "{$source ->\n [streamer] On dirait que <1>@{$handle} est hors ligne, mais ils recommandent de regarder :\n *[default] On dirait que <1>@{$handle} est hors ligne, mais nous recommandons de regarder :\n }", - "user-offline-no-recommendations": "On dirait que <1>@{$handle} est hors ligne maintenant.\nRevenez plus tard.", - "streaming-title": "diffusion de {$title}", - "viewer-count": "{$count ->\n [0] 0 spectateurs\n [1] 1 spectateur\n *[other] {$count} spectateurs\n }", - "pds-selector-title": "Nouveau sur Atmosphere ?", - "pds-selector-description": "Vous devrez sélectionner un PDS (Serveur de Données Personnel) pour accéder aux applications sur Atmosphere, comme Bluesky, Tangled et Spark.", - "pds-selector-custom-label": "Un autre PDS", - "pds-selector-custom-description": "Saisissez l'URL de votre propre hôte PDS", - "pds-selector-custom-url-label": "URL PDS personnalisée", - "pds-selector-custom-url-placeholder": "https://pds.exemple.com", - "pds-selector-learn-more": "En savoir plus sur l'auto-hébergement", - "pds-selector-info": "Chaque hôte a ses propres politiques et normes de fiabilité. Vos données ATProto se trouvent sur l'hôte que vous choisissez et vous pouvez migrer ultérieurement. Remarque : Streamplace a ses propres règles de modération — vous pouvez être banni de Streamplace quel que soit l'hôte choisi.", - "pds-selector-read-policies": "Lisez les Conditions d'utilisation et la Politique de confidentialité de {$label} avant de continuer.", - "pds-selector-handle-policy-checkbox": "J'ai lu et j'accepte la politique des identifiants", - "login-show-live-on-bluesky": "Afficher quand je suis en direct sur Bluesky", - "login-show-live-on-bluesky-description": "Ajoute l'anneau rouge LIVE à votre avatar Bluesky pendant vos streams et permet à Streamplace de publier des annonces pour vous. Décochez la case pour vous connecter sans accorder aucun accès à votre compte Bluesky." -} diff --git a/js/i18n/public/locales/fr-FR/settings.json b/js/i18n/public/locales/fr-FR/settings.json deleted file mode 100644 index 4cdf81ac7..000000000 --- a/js/i18n/public/locales/fr-FR/settings.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "Télécharger la nouvelle mise à jour", - "check-for-updates": "Vérifier les mises à jour", - "bundled-runtype": "Inclus", - "ota-runtype": "Over-the-Air (OTA)", - "recovery-runtype": "Mode de récupération", - "modal-latest-version": "Vous utilisez la dernière version.", - "modal-no-update-available": "Vous avez la dernière version de Streamplace, génial !", - "modal-update-available-title": "Mise à jour disponible", - "modal-update-available-description": "Une nouvelle version de Streamplace est prête à télécharger", - "modal-update-failed": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via {$store}.", - "modal-update-failed-title": "Échec de la mise à jour", - "modal-update-failed-description": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via {$store}.", - "button-reload-app-on-update": "Appliquer la mise à jour (l'application va se recharger)", - "use-custom-node": "Utiliser un nœud personnalisé", - "default-url": "Par défaut : {$url}", - "enter-custom-node-url": "Saisir l'URL du nœud personnalisé", - "save-button": "Enregistrer", - "try-new-web": "Essayer la nouvelle web (bêta)", - "try-new-web-description": "Passez à la nouvelle interface web. Vous pouvez revenir à tout moment depuis ce même réglage.", - "try-old-app": "Utiliser l'interface d'origine", - "try-old-app-description": "Revenir à l'interface d'origine de Streamplace.", - "language-selection": "Langue", - "language-selection-description": "Choisissez votre langue préférée", - "help-translate": "Aidez-nous à traduire Streamplace", - "help-translate-description": "Nous recherchons des bénévoles pour aider à traduire Streamplace dans plus de langues. Si vous êtes intéressé, contactez-nous sur Discord ou GitHub !", - "currently-translating": "Les traductions sont en cours", - "currently-translating-description": "Certaines parties de l'application peuvent sembler incomplètes. Merci pour votre patience !", - "debug-recording-title": "Autoriser {$host} à enregistrer votre diffusion en direct pour le débogage et l'amélioration du service", - "debug-recording-description": "Optionnel", - "input-search-languages": "Rechercher des langues...", - "manage-keys": "Gérer les Clés", - "settings-title": "Paramètres", - "loading": "Chargement...", - "error": "Erreur", - "cancel": "Annuler", - "confirm": "Confirmer", - "welcome-user": "Bienvenue, {$username} !", - "notification-count": "{$count ->\n [0] Aucune notification\n [1] Une notification\n *[other] {$count} notifications\n }", - "search-placeholder": "Rechercher...", - "message-input": "Saisissez votre message...", - "success": "Succès", - "warning": "Attention", - "info": "Information", - "close": "Fermer", - "open": "Ouvrir", - "delete": "Supprimer", - "edit": "Modifier", - "create": "Créer", - "update": "Mettre à jour", - "refresh": "Actualiser", - "save": "Enregistrer", - "cancel-button": "Annuler", - "ok": "OK", - "yes": "Oui", - "no": "Non", - "continue": "Continuer", - "back": "Retour", - "next": "Suivant", - "finish": "Terminer", - "about": "À propos", - "account": "Compte", - "advanced": "Avancé", - "danmu": "Danmu", - "developer": "Développeur", - "languages": "Langues", - "privacy-security": "Confidentialité et Sécurité", - "streaming": "Diffusion", - "notifications": "Notifications", - "sign-in": "Se connecter", - "log-out": "Se déconnecter", - "account-greeting": "Salut, @{$handle}.", - "edit-profile-bluesky": "Modifier le profil (sur Bluesky)", - "change-name-color": "Changer la couleur du nom", - "key-management": "Gestion des Clés", - "key-manager": "Gestionnaire de Clés", - "your-stream-pubkeys": "Vos Clés Publiques de Diffusion", - "no-keys": "Aucune clé configurée", - "pubkey-description": "Les clés publiques sont associées aux clés de diffusion (utilisées dans le logiciel de streaming) pour signer et vérifier votre diffusion", - "keys-count": "{$count ->\n [one] {$count} clé\n [many] {$count} clés\n *[other] {$count} clés\n }", - "webhooks": "Webhooks", - "webhook-integrations": "Intégrations de Webhook", - "webhook-integrations-description": "Connectez des services externes pour recevoir des mises à jour en temps réel sur vos diffusions", - "create-webhook": "Créer un Webhook", - "edit-webhook": "Modifier le Webhook", - "delete-webhook": "Supprimer le Webhook", - "no-webhooks-yet": "Aucun webhook configuré pour le moment", - "failed-load-webhooks": "Échec du chargement des webhooks", - "webhook-will-no-longer-receive-events": "Ce webhook ne recevra plus d'événements", - "create-first-webhook-description": "Créez votre premier webhook pour commencer à recevoir des événements de diffusion", - "example-captain-hook": "Capitaine Crochet", - "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", - "activates-on": "S'active sur :", - "events-livestream": "Événements de Diffusion en Direct", - "events-chat": "Événements de Chat", - "untitled-webhook": "Webhook Sans Titre", - "inactive": "Inactif", - "debug-recording": "Enregistrement de Débogage", - "danmu-enabled": "Activer Danmu", - "danmu-enabled-description": "Afficher les messages de chat en direct sous forme de commentaires flottants sur votre écran", - "danmu-opacity": "Opacité", - "danmu-speed": "Vitesse", - "danmu-lane-count": "Nombre de Voies", - "danmu-max-messages": "Messages Maximum", - "app-version-description": "Informations sur la version actuelle", - "confirm-delete": "Êtes-vous sûr de vouloir supprimer ceci ?", - "action-cannot-be-undone": "Cette action ne peut pas être annulée", - "name-optional": "Nom (optionnel)", - "deleting": "Suppression...", - "saving": "Enregistrement...", - "go-to-dashboard": "Aller au Tableau de Bord", - "need-setup-live-dashboard": "Vous devez d'abord configurer une diffusion en direct dans le tableau de bord", - "no-languages-found": "Aucune langue trouvée", - "backup-save": "Enregistrer les paramètres de sauvegarde", - "backup-saving": "Enregistrement des paramètres de sauvegarde...", - "backup-secret-key-set-placeholder": "(Mot de passe déjà défini)", - "backup-error-invalid-endpoint": "Doit être un nom de domaine valide", - "backup-error-invalid-bucket": "Doit contenir uniquement des lettres minuscules, des chiffres, des points et des traits d'union", - "backup-error-invalid-segment-duration": "Doit être compris entre 1 et 60 secondes", - "backup-error-load-failed": "Échec du chargement des paramètres de stockage", - "backup-error-update-failed": "Échec de la mise à jour de l'état de la sauvegarde", - "backup-error-save-failed": "Échec de l'enregistrement des paramètres de stockage", - "backup-error-missing-secret": "Impossible de mettre à jour les paramètres S3 sans la clé secrète. Veuillez la saisir à nouveau.", - "backup-segment-duration-placeholder": "6", - "backup": "Sauvegarde", - "backup-enabled": "S3 Backup", - "backup-enabled-description": "Sauvegarder automatiquement les enregistrements dans un stockage compatible S3", - "backup-connection-url": "URL de connexion", - "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", - "backup-endpoint": "Point de terminaison", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "Compartiment", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "Clé d'accès", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "Clé secrète", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "show-password-in-url": "Afficher le mot de passe dans l'URL", - "show-password-in-url-description": "Afficher la clé secrète dans l'URL de connexion (si saisie)", - "recommendations-to-others": "Recommandations aux autres", - "recommendations-description": "Partagez jusqu'à 8 streameurs que vous recommandez à vos spectateurs", - "no-recommendations-yet": "Aucune recommandation configurée pour le moment", - "multistream": "Multidiffusion", - "multistream-targets": "Cibles de multidiffusion", - "multistream-description": "Envoyez automatiquement vos diffusions en direct Streamplace vers d'autres services comme Twitch ou YouTube.", - "create-multistream-target": "Créer une cible de multidiffusion", - "untitled-multistream-target": "Cible sans titre", - "failed-load-multistream-targets": "Échec du chargement des cibles de multidiffusion. Veuillez réessayer.", - "failed-toggle-multistream-target": "Échec du basculement de la cible de multidiffusion. Veuillez réessayer.", - "no-multistream-targets-yet": "Aucune cible pour le moment !", - "multistream-targets-count": "{$count ->\n [one] {$count} cible\n [many] {$count} cibles\n *[other] {$count} cibles\n }", - "multistream-delete-target-confirmation": "Êtes-vous sûr de vouloir supprimer \"{$target}\" ?", - "this-action-cannot-be-undone": "Cette action ne peut pas être annulée.", - "rtmp-target-name": "Cible RTMP", - "rtmp-target-url": "URL RTMP", - "rtmp-target-name-placeholder": "Ma cible de multidiffusion", - "multistream-create-target": "Créer la cible", - "multistream-edit-target": "Modifier la cible", - "created": "créé", - "status": "statut", - "branding": "Image de marque", - "branding-admin": "Administration de l'image de marque", - "branding-admin-description": "Personnalisez votre instance Streamplace. Notez que les paramètres peuvent prendre quelques heures à se propager.", - "branding-login-required": "Veuillez vous connecter pour gérer l'image de marque", - "branding-configuration": "Configuration", - "branding-text-settings": "Paramètres de texte", - "branding-colors": "Couleurs", - "branding-legal-links": "Liens légaux", - "branding-images": "Images", - "branding-broadcaster-did": "DID du diffuseur", - "branding-broadcaster-did-description": "Laisser vide pour utiliser la valeur par défaut du serveur", - "branding-site-title": "Titre du site", - "branding-site-title-placeholder": "Saisir un nouveau titre de site", - "branding-site-description": "Description du site", - "branding-site-description-placeholder": "Saisir la description du site", - "branding-default-streamer": "Streameur par défaut", - "branding-default-streamer-none": "Aucun", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "Effacer le streameur par défaut", - "branding-primary-color": "Couleur principale", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Couleur d'accent", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "Logo principal", - "branding-main-logo-description": "SVG, PNG ou JPEG (max 500 Ko)", - "branding-favicon": "Favicon", - "branding-favicon-description": "SVG, PNG ou ICO (max 100 Ko)", - "branding-sidebar-bg": "Image d'arrière-plan de la barre latérale", - "branding-sidebar-bg-description": "SVG, PNG ou JPEG (max 500 ko) - apparaît aligné en bas de la barre latérale, pleine largeur. Téléchargez une image avec opacité pour de meilleurs résultats, car il n'existe pas actuellement d'option d'opacité séparée.", - "branding-current": "Actuel : {$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "Télécharger le logo", - "branding-delete-logo": "Supprimer le logo", - "branding-upload-favicon": "Télécharger le favicon", - "branding-delete-favicon": "Supprimer le favicon", - "branding-upload-background": "Télécharger l'arrière-plan", - "branding-delete-background": "Supprimer l'arrière-plan", - "branding-web-only": "Les téléchargements d'images ne sont disponibles que sur le web.", - "refresh-branding": "Actualiser les ressources de l'image de marque", - "branding-add-legal-link": "Ajouter un lien légal", - "branding-edit-legal-link": "Modifier le lien légal", - "branding-legal-link-text-placeholder": "Texte du lien (ex., Politique de confidentialité)", - "branding-legal-link-url-placeholder": "URL (ex., https://exemple.com/confidentialite)", - "add": "Ajouter", - "active": "Actif", - "optional": "optionnel", - "branding-not-authenticated": "Veuillez d'abord vous connecter", - "branding-empty-value": "Veuillez saisir une valeur", - "branding-update-success": "{$key} mis à jour avec succès", - "branding-upload-success": "{$key} téléchargé avec succès", - "branding-delete-success": "{$key} supprimé avec succès", - "branding-upload-failed": "Échec du téléchargement", - "branding-delete-failed": "Échec de la suppression", - "branding-not-available": "Les téléchargements de fichiers ne sont disponibles que sur le web", - "node-legal-documents": "Documents spécifiques au diffuseur", - "badges": "Badges", - "badges-cosmetic-section": "Badges cosmétiques", - "badges-empty-state": "Vous n'avez pas encore reçu de badges.", - "badges-failed-load": "Échec du chargement des badges", - "badges-failed-update": "Échec de la mise à jour de la sélection de badges", - "badges-issued-by": "Délivré par {$issuer}", - "badges-streamer-section": "Badges du streameur", - "issue-badges": "Délivrer des badges", - "issue-badges-back-to-definitions": "Définitions de badges", - "issue-badges-badge-name": "Nom du badge", - "issue-badges-badge-name-placeholder": "ex. VIP, Supporter", - "issue-badges-badge-type": "Type de badge", - "issue-badges-choose-image": "Choisir une image", - "issue-badges-create-definition": "Créer une définition de badge", - "issue-badges-create-definition-description": "Définissez un nouveau type de badge qui peut être délivré aux spectateurs", - "issue-badges-create-definition-subtitle": "Définir un nouveau type de badge", - "issue-badges-definition-created": "Définition de badge créée", - "issue-badges-description-optional": "Description (optionnelle)", - "issue-badges-description-placeholder": "Description optionnelle pour ce badge", - "issue-badges-failed-create": "Échec de la création de la définition de badge", - "issue-badges-failed-issue": "Échec de la délivrance du badge", - "issue-badges-failed-load": "Échec du chargement des définitions de badges", - "issue-badges-image-optional": "Image (optionnelle)", - "issue-badges-image-too-large": "L'image doit faire moins de 256 Ko", - "issue-badges-image-web-only": "Le téléchargement d'images n'est disponible que sur le web", - "issue-badges-issue-badge": "Délivrer un badge", - "issue-badges-issue-badge-description": "Délivrez le badge \"{$name}\" à un spectateur par son DID", - "issue-badges-issued": "Badge délivré", - "issue-badges-issued-to": "Délivré à {$did}", - "issue-badges-manage-description": "Créez des définitions de badges et délivrez-les aux spectateurs", - "issue-badges-recipient-did": "DID du destinataire", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "Appuyer pour délivrer", - "issue-badges-your-definitions": "Vos définitions de badges" -} diff --git a/js/i18n/public/locales/pt-BR/common.json b/js/i18n/public/locales/pt-BR/common.json deleted file mode 100644 index 348dd8f80..000000000 --- a/js/i18n/public/locales/pt-BR/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "Carregando...", - "error": "Erro", - "cancel": "Cancelar", - "confirm": "Confirmar", - "close": "Fechar", - "open": "Abrir", - "ok": "OK", - "yes": "Sim", - "no": "Não", - "continue": "Continuar", - "back": "Voltar", - "next": "Próximo", - "finish": "Concluir", - "save": "Salvar", - "delete": "Excluir", - "edit": "Editar", - "create": "Criar", - "update": "Atualizar", - "refresh": "Atualizar", - "success": "Sucesso", - "warning": "Aviso", - "info": "Informação", - "search-placeholder": "Pesquisar...", - "message-input": "Digite sua mensagem...", - "please-log-in-to-access-this-page": "Por favor, faça login para acessar esta página", - "go-to-settings": "Ir para Configurações", - "go-back": "Voltar", - "welcome-user": "Bem-vindo, {$username}!", - "notification-count": "{$count ->\n [0] Nenhuma notificação\n [1] Uma notificação\n *[other] {$count} notificações\n }", - "user-offline": "usuário offline", - "user-offline-message": "{$source ->\n [streamer] Parece que <1>@{$handle} está offline, mas eles recomendam assistir:\n *[default] Parece que <1>@{$handle} está offline, mas recomendamos assistir:\n }", - "user-offline-no-recommendations": "Parece que <1>@{$handle} está offline agora.\nVolte mais tarde.", - "streaming-title": "transmitindo {$title}", - "viewer-count": "{$count ->\n [0] 0 espectadores\n [1] 1 espectador\n *[other] {$count} espectadores\n }", - "pds-selector-title": "Novo no Atmosphere?", - "pds-selector-description": "Você precisará selecionar um PDS (Servidor de Dados Pessoal) para acessar apps no Atmosphere, como Bluesky, Tangled e Spark.", - "pds-selector-custom-label": "Outro PDS", - "pds-selector-custom-description": "Digite a URL do seu próprio host PDS", - "pds-selector-custom-url-label": "URL do PDS personalizado", - "pds-selector-custom-url-placeholder": "https://pds.exemplo.com", - "pds-selector-learn-more": "Saiba mais sobre auto-hospedagem", - "pds-selector-info": "Cada host tem suas próprias políticas e padrões de confiabilidade. Seus dados ATProto ficam no host que você escolher e você pode migrar depois. Nota: O Streamplace tem suas próprias regras de moderação — você pode ser banido do Streamplace independentemente do host escolhido.", - "pds-selector-read-policies": "Leia os Termos de Serviço e a Política de Privacidade de {$label} antes de continuar.", - "pds-selector-handle-policy-checkbox": "Li e concordo com a política de identificadores", - "login-show-live-on-bluesky": "Mostrar quando estou ao vivo no Bluesky", - "login-show-live-on-bluesky-description": "Adiciona o anel vermelho LIVE ao seu avatar do Bluesky enquanto você transmite e permite que o Streamplace publique anúncios por você. Desmarque para entrar sem conceder nenhum acesso à sua conta do Bluesky." -} diff --git a/js/i18n/public/locales/pt-BR/settings.json b/js/i18n/public/locales/pt-BR/settings.json deleted file mode 100644 index f0d956b57..000000000 --- a/js/i18n/public/locales/pt-BR/settings.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "Baixar Nova Atualização", - "check-for-updates": "Verificar Atualizações", - "bundled-runtype": "Empacotado", - "ota-runtype": "Over-the-Air (OTA)", - "recovery-runtype": "Modo de Recuperação", - "modal-latest-version": "Você está usando a versão mais recente.", - "modal-no-update-available": "Você está na versão mais recente do Streamplace, eba!", - "modal-update-available-title": "Atualização Disponível", - "modal-update-available-description": "Uma nova versão do Streamplace está pronta para download", - "modal-update-failed": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do {$store}.", - "modal-update-failed-title": "Atualização Falhou", - "modal-update-failed-description": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do {$store}.", - "button-reload-app-on-update": "Aplicar Atualização (o aplicativo será recarregado)", - "use-custom-node": "Usar Nó Personalizado", - "default-url": "Padrão: {$url}", - "enter-custom-node-url": "Digite a URL do nó personalizado", - "save-button": "Salvar", - "try-new-web": "Experimentar a nova web (beta)", - "try-new-web-description": "Mude para a nova interface web. Você pode voltar a qualquer momento nesta mesma configuração.", - "try-old-app": "Usar a interface original", - "try-old-app-description": "Volte para a interface original do Streamplace.", - "language-selection": "Idioma", - "language-selection-description": "Escolha seu idioma preferido", - "help-translate": "Ajude-nos a traduzir o Streamplace", - "help-translate-description": "Estamos procurando voluntários para ajudar a traduzir o Streamplace para mais idiomas. Se você estiver interessado, entre em contato conosco no Discord ou GitHub!", - "currently-translating": "As traduções estão a caminho", - "currently-translating-description": "Algumas partes do aplicativo podem parecer incompletas. Obrigado pela sua paciência!", - "debug-recording-title": "Permitir que {$host} grave sua transmissão ao vivo para depuração e melhoria do serviço", - "debug-recording-description": "Opcional", - "input-search-languages": "Pesquisar idiomas...", - "manage-keys": "Gerenciar Chaves", - "settings-title": "Configurações", - "loading": "Carregando...", - "error": "Erro", - "cancel": "Cancelar", - "confirm": "Confirmar", - "welcome-user": "Bem-vindo, {$username}!", - "notification-count": "{$count ->\n [0] Nenhuma notificação\n [1] Uma notificação\n *[other] {$count} notificações\n }", - "search-placeholder": "Pesquisar...", - "message-input": "Digite sua mensagem...", - "success": "Sucesso", - "warning": "Aviso", - "info": "Informação", - "close": "Fechar", - "open": "Abrir", - "delete": "Excluir", - "edit": "Editar", - "create": "Criar", - "update": "Atualizar", - "refresh": "Atualizar", - "save": "Salvar", - "cancel-button": "Cancelar", - "ok": "OK", - "yes": "Sim", - "no": "Não", - "continue": "Continuar", - "back": "Voltar", - "next": "Próximo", - "finish": "Finalizar", - "about": "Sobre", - "account": "Conta", - "advanced": "Avançado", - "danmu": "Danmu", - "developer": "Desenvolvedor", - "languages": "Idiomas", - "privacy-security": "Privacidade e Segurança", - "streaming": "Transmissão", - "notifications": "Notificações", - "sign-in": "Entrar", - "log-out": "Sair", - "account-greeting": "Olá, @{$handle}.", - "edit-profile-bluesky": "Editar perfil (no Bluesky)", - "change-name-color": "Mudar cor do nome", - "key-management": "Gerenciamento de Chaves", - "key-manager": "Gerenciador de Chaves", - "your-stream-pubkeys": "Suas Chaves Públicas de Transmissão", - "no-keys": "Nenhuma chave configurada", - "pubkey-description": "Chaves públicas são emparelhadas com chaves de transmissão (usadas em software de transmitindo) para assinar e verificar sua transmissão", - "keys-count": "{$count ->\n [one] {$count} chave\n [many] {$count} chaves\n *[other] {$count} chaves\n }", - "webhooks": "Webhooks", - "webhook-integrations": "Integrações de Webhook", - "webhook-integrations-description": "Conecte serviços externos para receber atualizações em tempo real sobre suas transmissões", - "create-webhook": "Criar Webhook", - "edit-webhook": "Editar Webhook", - "delete-webhook": "Excluir Webhook", - "no-webhooks-yet": "Nenhum webhook configurado ainda", - "failed-load-webhooks": "Falha ao carregar webhooks", - "webhook-will-no-longer-receive-events": "Este webhook não receberá mais eventos", - "create-first-webhook-description": "Crie seu primeiro webhook para começar a receber eventos de transmissão", - "example-captain-hook": "Capitão Gancho", - "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", - "activates-on": "Ativa em:", - "events-livestream": "Eventos de Transmissão ao Vivo", - "events-chat": "Eventos de Chat", - "untitled-webhook": "Webhook Sem Título", - "inactive": "Inativo", - "debug-recording": "Gravação de Depuração", - "danmu-enabled": "Ativar Danmu", - "danmu-enabled-description": "Exibir mensagens de chat ao vivo como comentários flutuantes na sua tela", - "danmu-opacity": "Opacidade", - "danmu-speed": "Velocidade", - "danmu-lane-count": "Número de Faixas", - "danmu-max-messages": "Mensagens Máximas", - "app-version-description": "Informações da versão atual", - "confirm-delete": "Tem certeza de que deseja excluir isso?", - "action-cannot-be-undone": "Esta ação não pode ser desfeita", - "name-optional": "Nome (opcional)", - "deleting": "Excluindo...", - "saving": "Salvando...", - "go-to-dashboard": "Ir para o Painel", - "need-setup-live-dashboard": "Você precisa configurar uma transmissão ao vivo no painel primeiro", - "no-languages-found": "Nenhum idioma encontrado", - "backup-save": "Salvar configurações de backup", - "backup-saving": "Salvando configurações de backup...", - "backup-secret-key-set-placeholder": "(Senha já definida)", - "backup-error-invalid-endpoint": "Deve ser um nome de domínio válido", - "backup-error-invalid-bucket": "Deve conter apenas letras minúsculas, números, pontos e hifens", - "backup-error-invalid-segment-duration": "Deve estar entre 1 e 60 segundos", - "backup-error-load-failed": "Falha ao carregar as configurações de armazenamento", - "backup-error-update-failed": "Falha ao atualizar o status do backup", - "backup-error-save-failed": "Falha ao salvar as configurações de armazenamento", - "backup-error-missing-secret": "Não é possível atualizar as configurações do S3 sem a chave secreta. Por favor, insira-a novamente.", - "backup-segment-duration-placeholder": "6", - "backup": "Backup", - "backup-enabled": "S3 Backup", - "backup-enabled-description": "Fazer backup automaticamente das gravações em armazenamento compatível com S3", - "backup-connection-url": "URL de conexão", - "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", - "backup-endpoint": "Endpoint", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "Bucket", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "Chave de acesso", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "Chave secreta", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "show-password-in-url": "Mostrar senha na URL", - "show-password-in-url-description": "Exibir a chave secreta na URL de conexão (se inserida)", - "recommendations-to-others": "Recomendações para outros", - "recommendations-description": "Compartilhe até 8 streamers que você recomenda aos seus espectadores", - "no-recommendations-yet": "Nenhuma recomendação configurada ainda", - "multistream": "Multitransmissão", - "multistream-targets": "Destinos de multitransmissão", - "multistream-description": "Envie automaticamente suas transmissões ao vivo do Streamplace para outros serviços como Twitch ou YouTube.", - "create-multistream-target": "Criar destino de multitransmissão", - "untitled-multistream-target": "Destino sem título", - "failed-load-multistream-targets": "Falha ao carregar os destinos de multitransmissão. Por favor, tente novamente.", - "failed-toggle-multistream-target": "Falha ao alternar o destino de multitransmissão. Por favor, tente novamente.", - "no-multistream-targets-yet": "Nenhum destino ainda!", - "multistream-targets-count": "{$count ->\n [one] {$count} destino\n [many] {$count} destinos\n *[other] {$count} destinos\n }", - "multistream-delete-target-confirmation": "Tem certeza de que deseja excluir \"{$target}\"?", - "this-action-cannot-be-undone": "Esta ação não pode ser desfeita.", - "rtmp-target-name": "Destino RTMP", - "rtmp-target-url": "URL RTMP", - "rtmp-target-name-placeholder": "Meu destino de multitransmissão", - "multistream-create-target": "Criar destino", - "multistream-edit-target": "Editar destino", - "created": "criado", - "status": "status", - "branding": "Identidade visual", - "branding-admin": "Administração de identidade visual", - "branding-admin-description": "Personalize sua instância do Streamplace. As configurações podem levar algumas horas para se propagar.", - "branding-login-required": "Faça login para gerenciar a identidade visual", - "branding-configuration": "Configuração", - "branding-text-settings": "Configurações de texto", - "branding-colors": "Cores", - "branding-legal-links": "Links legais", - "branding-images": "Imagens", - "branding-broadcaster-did": "DID do transmissor", - "branding-broadcaster-did-description": "Deixe vazio para usar o padrão do servidor", - "branding-site-title": "Título do site", - "branding-site-title-placeholder": "Digite o novo título do site", - "branding-site-description": "Descrição do site", - "branding-site-description-placeholder": "Digite a descrição do site", - "branding-default-streamer": "Streamer padrão", - "branding-default-streamer-none": "Nenhum", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "Limpar streamer padrão", - "branding-primary-color": "Cor principal", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Cor de destaque", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "Logo principal", - "branding-main-logo-description": "SVG, PNG ou JPEG (máx. 500KB)", - "branding-favicon": "Favicon", - "branding-favicon-description": "SVG, PNG ou ICO (máx. 100KB)", - "branding-sidebar-bg": "Imagem de fundo da barra lateral", - "branding-sidebar-bg-description": "SVG, PNG ou JPEG (máx. 500kb) - aparece alinhada na parte inferior da barra lateral, largura total. Faça upload de uma imagem com opacidade para melhores resultados, pois atualmente não há uma opção de opacidade separada.", - "branding-current": "Atual: {$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "Enviar logo", - "branding-delete-logo": "Excluir logo", - "branding-upload-favicon": "Enviar favicon", - "branding-delete-favicon": "Excluir favicon", - "branding-upload-background": "Enviar plano de fundo", - "branding-delete-background": "Excluir plano de fundo", - "branding-web-only": "O upload de imagens só está disponível na web.", - "refresh-branding": "Atualizar recursos de identidade visual", - "branding-add-legal-link": "Adicionar link legal", - "branding-edit-legal-link": "Editar link legal", - "branding-legal-link-text-placeholder": "Texto do link (ex., Política de Privacidade)", - "branding-legal-link-url-placeholder": "URL (ex., https://exemplo.com/privacidade)", - "add": "Adicionar", - "active": "Ativo", - "optional": "opcional", - "branding-not-authenticated": "Faça login primeiro", - "branding-empty-value": "Por favor, insira um valor", - "branding-update-success": "{$key} atualizado com sucesso", - "branding-upload-success": "{$key} enviado com sucesso", - "branding-delete-success": "{$key} excluído com sucesso", - "branding-upload-failed": "Falha ao enviar", - "branding-delete-failed": "Falha ao excluir", - "branding-not-available": "O upload de arquivos só está disponível na web", - "node-legal-documents": "Documentos específicos do transmissor", - "badges": "Emblemas", - "badges-cosmetic-section": "Emblemas cosméticos", - "badges-empty-state": "Você ainda não recebeu nenhum emblema.", - "badges-failed-load": "Falha ao carregar emblemas", - "badges-failed-update": "Falha ao atualizar a seleção de emblemas", - "badges-issued-by": "Emitido por {$issuer}", - "badges-streamer-section": "Emblemas do streamer", - "issue-badges": "Emitir emblemas", - "issue-badges-back-to-definitions": "Definições de emblemas", - "issue-badges-badge-name": "Nome do emblema", - "issue-badges-badge-name-placeholder": "ex. VIP, Apoiador", - "issue-badges-badge-type": "Tipo de emblema", - "issue-badges-choose-image": "Escolher imagem", - "issue-badges-create-definition": "Criar definição de emblema", - "issue-badges-create-definition-description": "Defina um novo tipo de emblema que pode ser emitido para espectadores", - "issue-badges-create-definition-subtitle": "Defina um novo tipo de emblema", - "issue-badges-definition-created": "Definição de emblema criada", - "issue-badges-description-optional": "Descrição (opcional)", - "issue-badges-description-placeholder": "Descrição opcional para este emblema", - "issue-badges-failed-create": "Falha ao criar a definição de emblema", - "issue-badges-failed-issue": "Falha ao emitir o emblema", - "issue-badges-failed-load": "Falha ao carregar as definições de emblemas", - "issue-badges-image-optional": "Imagem (opcional)", - "issue-badges-image-too-large": "A imagem deve ter menos de 256KB", - "issue-badges-image-web-only": "O upload de imagens só está disponível na web", - "issue-badges-issue-badge": "Emitir emblema", - "issue-badges-issue-badge-description": "Emita o emblema \"{$name}\" para um espectador pelo seu DID", - "issue-badges-issued": "Emblema emitido", - "issue-badges-issued-to": "Emitido para {$did}", - "issue-badges-manage-description": "Crie definições de emblemas e emita-os para espectadores", - "issue-badges-recipient-did": "DID do destinatário", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "Toque para emitir", - "issue-badges-your-definitions": "Suas definições de emblemas" -} diff --git a/js/i18n/public/locales/ro-RO/common.json b/js/i18n/public/locales/ro-RO/common.json deleted file mode 100644 index 8b7137fdb..000000000 --- a/js/i18n/public/locales/ro-RO/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "Se încarcă...", - "error": "Eroare", - "cancel": "Anulare", - "confirm": "Confirmare", - "close": "Închidere", - "open": "Deschidere", - "ok": "OK", - "yes": "Da", - "no": "Nu", - "continue": "Continuare", - "back": "Înapoi", - "next": "Următorul", - "finish": "Finalizare", - "save": "Salvare", - "delete": "Ștergere", - "edit": "Editare", - "create": "Creare", - "update": "Actualizare", - "refresh": "Reîmprospătare", - "success": "Succes", - "warning": "Avertisment", - "info": "Informație", - "search-placeholder": "Căutare...", - "message-input": "Introduceți-vă mesajul...", - "please-log-in-to-access-this-page": "Vă rugăm să vă conectați pentru a accesa această pagină", - "go-to-settings": "Mergeți la Setări", - "go-back": "Mergeți înapoi", - "welcome-user": "Bun venit, {$username}!", - "notification-count": "{$count ->\n [0] Nici o notificare\n [1] O notificare\n *[other] {$count} (de) notificări\n }", - "user-offline": "utilizatorul este offline", - "user-offline-message": "{$source ->\n [streamer] Se pare că <1>@{$handle} este offline, dar recomandă să vizionați:\n *[default] Se pare că <1>@{$handle} este offline, dar vă recomandăm să vizionați:\n }", - "user-offline-no-recommendations": "Se pare că <1>@{$handle} este offline acum.\nReveniți mai târziu.", - "streaming-title": "transmisie în direct {$title}", - "viewer-count": "{$count ->\n [0] 0 vizionări\n [1] 1 vizionare\n *[other] {$count} (de) vizionări\n }", - "pds-selector-title": "Nou pe Atmosphere?", - "pds-selector-description": "Va trebui să selectați un PDS (Personal Data Server) pentru a accesa aplicațiile de pe Atmosphere, cum ar fi Bluesky, Tangled și Spark.", - "pds-selector-custom-label": "Alt PDS", - "pds-selector-custom-description": "Introduceți adresa URL a propriei gazde PDS", - "pds-selector-custom-url-label": "URL PDS personalizat", - "pds-selector-custom-url-placeholder": "https://pds.exemplu.ro", - "pds-selector-learn-more": "Aflați mai multe despre găzduirea proprie", - "pds-selector-info": "Fiecare gazdă are propriile politici și standarde de fiabilitate. Datele dvs. ATProto se află pe gazda pe care o alegeți și le puteți migra ulterior. Notă: Streamplace are propriile reguli de moderare - accesul dvs. poate fi interzis pe Streamplace indiferent de gazda pe care o alegeți.", - "pds-selector-read-policies": "Citiți Termenii și condițiile și Politica de confidențialitate ale {$label} înainte de a continua.", - "pds-selector-handle-policy-checkbox": "Am citit și sunt de acord cu politica de gestionare", - "login-show-live-on-bluesky": "Arată când sunt live pe Bluesky", - "login-show-live-on-bluesky-description": "Adaugă inelul roșu LIVE avatarului dvs. de Bluesky în timp ce transmiteți și permite Streamplace să publice anunțuri în numele dvs. Debifați pentru a vă conecta fără a acorda niciun acces la contul dvs. de Bluesky." -} diff --git a/js/i18n/public/locales/ro-RO/settings.json b/js/i18n/public/locales/ro-RO/settings.json deleted file mode 100644 index 38ee3e585..000000000 --- a/js/i18n/public/locales/ro-RO/settings.json +++ /dev/null @@ -1,211 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "Descărcați noua actualizare", - "check-for-updates": "Căutați actualizări", - "bundled-runtype": "Bundled", - "ota-runtype": "Over-the-Air (OTA)", - "recovery-runtype": "Mod de recuperare", - "modal-latest-version": "Aveți cea mai recentă versiune.", - "modal-no-update-available": "Aveți cea mai recentă versiune de Streamplace, ura!", - "modal-update-available-title": "Actualizare disponibilă", - "modal-update-available-description": "O nouă versiune de Streamplace este gata de descărcare", - "modal-update-failed": "Căutarea actualizării a eșuat. Este posibil să fie nevoie să actualizați aplicația prin {$store}.", - "modal-update-failed-title": "Actualizare eșuată", - "modal-update-failed-description": "Căutarea actualizării a eșuat. Este posibil să fie nevoie să actualizați aplicația prin {$store}.", - "button-reload-app-on-update": "Aplicați actualizarea (va reîncărca aplicația)", - "use-custom-node": "Utilizați nodul personalizat", - "default-url": "Default: {$url}", - "enter-custom-node-url": "Introduceți adresa URL a nodului personalizat", - "save-button": "Salvare", - "try-new-web": "Încercați noul web (beta)", - "try-new-web-description": "Comutați la noua interfață web. Puteți reveni oricând din aceeași setare.", - "try-old-app": "Folosiți interfața originală", - "try-old-app-description": "Reveniți la interfața originală Streamplace.", - "language-selection": "Limbă", - "language-selection-description": "Alegeți-vă limba preferată", - "input-search-languages": "Căutați limbi...", - "help-translate": "Ajutați-ne să traducem Streamplace", - "help-translate-description": "Căutăm voluntari care să ne ajute la traducerea Streamplace în mai multe limbi. Dacă sunteți interesați, vă rugăm să ne contactați pe Discord sau GitHub!", - "currently-translating": "Traducerile sunt pe drum", - "currently-translating-description": "Este posibil ca unele părți ale aplicației să pară incomplete. Vă mulțumim pentru răbdare!", - "debug-recording-title": "Permiteți-i lui {$host} să înregistreze transmisiunea live pentru depanare și îmbunătățirea serviciului", - "debug-recording-description": "Opțional", - "manage-keys": "Gestionare chei", - "settings-title": "Setări", - "about": "Despre", - "account": "Cont", - "advanced": "Complex", - "danmu": "Danmu", - "developer": "Dezvoltator", - "languages": "Limbi", - "privacy-security": "Confidențialitate și securitate", - "streaming": "Streaming", - "notifications": "Notificări", - "cancel": "Anulare", - "create": "Creare", - "delete": "Ștergere", - "refresh": "Reîmprospătare", - "sign-in": "Conectare", - "update": "Actualizare", - "log-out": "Deconectare", - "optional": "opțional", - "account-greeting": "Hei, @{$handle}.", - "edit-profile-bluesky": "Editare profil (pe Bluesky)", - "change-name-color": "Schimbați culoarea numelui", - "key-management": "Gestionare chei", - "key-manager": "Manager cheie", - "your-stream-pubkeys": "Cheile publice ale transmisiunii dvs.", - "no-keys": "Nicio cheie configurată", - "pubkey-description": "Cheile publice sunt asociate cu cheile de transmisiune (utilizate în software-ul de streaming) pentru a semna și verifica transmisiunea.", - "keys-count": "{$count ->\n [one] {$count} cheie\n *[other] {$count} chei\n }", - "recommendations": "Recomandări", - "manage-recommendations": "Gestionați recomandările", - "recommendations-to-others": "Recomandări pentru Alții", - "recommendations-description": "Distribuie până la 8 streameri pe care îi recomandați spectatorilor dvs.", - "no-recommendations-yet": "Nu au fost configurate recomandări încă", - "add-recommendation": "Adăugare recomandare", - "streamer-did": "DID Streamer", - "recommendations-count": "{$count ->\n [one] {$count} recomandare\n *[other] {$count} (de) recomandări\n }", - "webhooks": "Webhook-uri", - "webhook-integrations": "Integrari Webhook", - "webhook-integrations-description": "Conectați servicii externe pentru a primi actualizări în timp real despre transmisiunile dvs.", - "create-webhook": "Creare Webhook", - "edit-webhook": "Editare Webhook", - "delete-webhook": "Ștergere Webhook", - "no-webhooks-yet": "Nu sunt încă configurate webhook-uri", - "failed-load-webhooks": "Nu s-au putut încărca webhook-urile", - "webhook-will-no-longer-receive-events": "Acest webhook nu va mai primi evenimente", - "create-first-webhook-description": "Creați-vă primul webhook pentru a începe să primiți evenimente de transmisiune", - "example-captain-hook": "Captain Hook", - "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} (de) webhook-uri\n }", - "activates-on": "Se activează la:", - "events": "Evenimente", - "events-livestream": "Evenimente în transmisiuni", - "events-chat": "Evenimente de conversașie", - "untitled-webhook": "Webhook fără titlu", - "inactive": "Inactiv", - "active": "Activ", - "multistream": "Multi-transmisiune", - "multistream-targets": "Ținte multi-transmisiune", - "multistream-description": "Distribuie automat transmisiunile tale live Streamplace către alte servicii de streaming precum Twitch sau YouTube.", - "create-multistream-target": "Creare țintă multi-transmisiune", - "untitled-multistream-target": "Țintă fără titlu", - "failed-load-multistream-targets": "Nu s-au putut încărca țintele multi-transmisiune. Vă rugăm să încercați din nou.", - "failed-toggle-multistream-target": "Nu s-a putut comuta ținta multi-transmisiune. Vă rugăm să încercați din nou.", - "failed-delete-multistream-target": "Ștergerea țintei multi-transmisiune nu a reușit. Încercați din nou.", - "no-multistream-targets-yet": "Nu există încă ținte!", - "multistream-targets-count": "{$count ->\n [one] {$count} țintă\n *[other] {$count} (de) ținte\n }", - "multistream-delete-target-confirmation": "Sunteți sigur că vreți să ștergeți „{$target}”?", - "this-action-cannot-be-undone": "Această acțiune nu poate fi anulată.", - "rtmp-target-name": "Țintă RTMP", - "rtmp-target-url": "URL RTMP", - "rtmp-target-name-placeholder": "Ținta mea transmisiune", - "multistream-create-target": "Creare țintă", - "multistream-edit-target": "Editare țintă", - "created": "creată", - "status": "stare", - "debug-recording": "Depanare înregistrare", - "danmu-enabled": "Activare Danmu", - "danmu-enabled-description": "Afișați mesajele din chatul live ca comentarii plutitoare pe ecran", - "danmu-opacity": "Opacitate", - "danmu-speed": "Viteză", - "danmu-lane-count": "Număr de benzi", - "danmu-max-messages": "Mesaje maxime", - "app-version-description": "Nu sunt disponibile actualizări în acest moment", - "confirm-delete": "Sigur vreți să ștergeți asta?", - "action-cannot-be-undone": "Această acțiune nu poate fi anulată", - "name-optional": "Nume (opțional)", - "deleting": "Se șterge...", - "saving": "Se salvează...", - "go-to-dashboard": "Mergeți la tabloul de bord", - "need-setup-live-dashboard": "Trebuie să configurați mai întâi transmisiunile? Vizitați tabloul de bord al transmisiunilor", - "no-languages-found": "Nu s-au găsit limbi", - "branding": "Branding", - "branding-admin": "Administrarea brandingului", - "branding-admin-description": "Personalizați-vă instanța Streamplace. Rețineți că propagarea setărilor poate dura câteva ore.", - "branding-login-required": "Vă rugăm să vă conectați pentru a gestiona brandingul", - "branding-configuration": "Configurare", - "branding-text-settings": "Setări text", - "branding-colors": "Culori", - "branding-legal-links": "Link-uri legale", - "branding-images": "Imagini", - "branding-broadcaster-did": "DID radiodifuzor", - "branding-broadcaster-did-description": "Lăsați gol pentru a utiliza valorile implicite ale serverului", - "branding-site-title": "Titlul site-ului", - "branding-site-title-placeholder": "Introduceți un nou titlu de site", - "branding-site-description": "Descrierea site-ului", - "branding-site-description-placeholder": "Introduceți descrierea site-ului", - "branding-default-streamer": "Streamer implicit", - "branding-default-streamer-none": "Nimic", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "Ștergeți Streamerul implicit", - "branding-primary-color": "Culoare primară", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Culoare de accent", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "Logo principal", - "branding-main-logo-description": "SVG, PNG, or JPEG (max 500KB)", - "branding-favicon": "Favicon", - "branding-favicon-description": "SVG, PNG, sau ICO (maxim 100KB)", - "branding-sidebar-bg": "Imagine de fundal din bara laterală", - "branding-sidebar-bg-description": "SVG, PNG sau JPEG (maxim 500kb) - apare aliniat la partea de jos a barei laterale, pe lățime completă. Încărcați o imagine cu opacitate pentru cele mai bune rezultate, deoarece în prezent nu există o opțiune separată de opacitate.", - "branding-current": "Current: {$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "Încărcare logo", - "branding-delete-logo": "Ștergere logo", - "branding-upload-favicon": "Încărcare Favicon", - "branding-delete-favicon": "Ștergere Favicon", - "branding-upload-background": "Încărcare imagine de fundal", - "branding-delete-background": "Ștergere imagine de fundal", - "branding-web-only": "Încărcarea imaginilor este disponibilă doar pe web.", - "refresh-branding": "Actualizați elementele de branding", - "branding-add-legal-link": "Adăugați link legal", - "branding-edit-legal-link": "Editați linkul legal", - "branding-legal-link-text-placeholder": "Textul linkului (de exemplu, Politica de confidențialitate)", - "branding-legal-link-url-placeholder": "Adresa URL (de ex., https://example.com/privacy)", - "add": "Adăugare", - "edit": "Editare", - "branding-not-authenticated": "Vă rugăm să vă conectați mai întâi", - "branding-empty-value": "Vă rugăm să introduceți o valoare", - "branding-update-success": "{$key} a fost actualizat cu succes", - "branding-upload-success": "{$key} a fost încărcat cu succes", - "branding-delete-success": "{$key} a fost șters cu succes", - "branding-upload-failed": "Încărcare eșuată", - "branding-delete-failed": "Ștergere eșuată", - "branding-not-available": "Încărcarea de fișiere este disponibilă doar pe web.", - "node-legal-documents": "Documente specifice radiodifuzorului", - "badges": "Insigne", - "badges-cosmetic-section": "Insigne cosmetice", - "badges-empty-state": "Nu ți-a fost acordată nicio insignă încă.", - "badges-failed-load": "Nu s-au putut încărca insignele", - "badges-failed-update": "Nu s-a putut actualiza selecția de insigne", - "badges-issued-by": "Acordată de {$issuer}", - "badges-streamer-section": "Insigne de la streameri", - "issue-badges": "Emitere insigne", - "issue-badges-back-to-definitions": "Definiții insigne", - "issue-badges-badge-name": "Numele insignei", - "issue-badges-badge-name-placeholder": "ex. VIP, Susținător", - "issue-badges-badge-type": "Tipul insignei", - "issue-badges-choose-image": "Alegeți imaginea", - "issue-badges-create-definition": "Creare definiție insignă", - "issue-badges-create-definition-description": "Definiți un nou tip de insignă care poate fi acordată spectatorilor", - "issue-badges-create-definition-subtitle": "Definiți un nou tip de insignă", - "issue-badges-definition-created": "Definiție insignă creată", - "issue-badges-description-optional": "Descriere (opțional)", - "issue-badges-description-placeholder": "Descriere opțională pentru această insignă", - "issue-badges-failed-create": "Nu s-a putut crea definiția insignei", - "issue-badges-failed-issue": "Nu s-a putut emite insigna", - "issue-badges-failed-load": "Nu s-au putut încărca definițiile insignelor", - "issue-badges-image-optional": "Imagine (opțional)", - "issue-badges-image-too-large": "Imaginea trebuie să fie mai mică de 256KB", - "issue-badges-image-web-only": "Încărcarea imaginilor este disponibilă doar pe web", - "issue-badges-issue-badge": "Emite insignă", - "issue-badges-issue-badge-description": "Emite insigna „{$name}\" unui spectator prin DID-ul său", - "issue-badges-issued": "Insignă emisă", - "issue-badges-issued-to": "Emisă pentru {$did}", - "issue-badges-manage-description": "Creați definiții de insigne și emiteți-le spectatorilor", - "issue-badges-recipient-did": "DID destinatar", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "Atingeți pentru a emite", - "issue-badges-your-definitions": "Definițiile dvs. de insigne" -} diff --git a/js/i18n/public/locales/zh-Hans/common.json b/js/i18n/public/locales/zh-Hans/common.json deleted file mode 100644 index 082dd6d61..000000000 --- a/js/i18n/public/locales/zh-Hans/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "正在加载...", - "error": "错误", - "cancel": "取消", - "confirm": "确认", - "close": "关闭", - "open": "开启", - "ok": "确定", - "yes": "是", - "no": "否", - "continue": "继续", - "back": "返回", - "next": "下一步", - "finish": "完成", - "save": "保存", - "delete": "删除", - "edit": "编辑", - "create": "创建", - "update": "更新", - "refresh": "刷新", - "success": "成功", - "warning": "警告", - "info": "信息", - "search-placeholder": "搜索...", - "message-input": "输入您的消息...", - "please-log-in-to-access-this-page": "请登录以访问此页面", - "go-to-settings": "前往设置", - "go-back": "返回", - "welcome-user": "欢迎,{$username}!", - "notification-count": "{$count ->\n [0] 无通知\n *[other] {$count} 则通知\n }", - "user-offline": "用户离线", - "user-offline-message": "{$source ->\n [streamer] 看起来 <1>@{$handle} 离线了,但他们推荐观看:\n *[default] 看起来 <1>@{$handle} 离线了,但我们推荐观看:\n }", - "user-offline-no-recommendations": "看起来 <1>@{$handle} 离线了。\n请稍后再来看看。", - "streaming-title": "正在直播 {$title}", - "viewer-count": "{$count} 位观众", - "pds-selector-title": "刚接触 Atmosphere 吗?", - "pds-selector-description": "您需要选择一个 PDS (个人数据服务器) 来访问 Atmosphere 上的应用程序,例如 Bluesky、Tangled 和 Spark。", - "pds-selector-custom-label": "另一个 PDS", - "pds-selector-custom-description": "请输入您自己的 PDS 主机网址", - "pds-selector-custom-url-label": "自定义 PDS 网址", - "pds-selector-custom-url-placeholder": "https://pds.example.com", - "pds-selector-learn-more": "了解有关自组主机的更多信息", - "pds-selector-info": "每个主机都有自己的政策和可靠性标准。您的 ATProto 数据存储在您选择的主机上,您可以以后进行迁移。注意:Streamplace 有自己的审核规则——无论您选择哪个主机,都可能会被 Streamplace 禁止使用。", - "pds-selector-read-policies": "在继续之前,请阅读 {$label} 的服务条款和隐私政策。", - "pds-selector-handle-policy-checkbox": "我已阅读并同意处理政策", - "login-show-live-on-bluesky": "在 Bluesky 上显示我的直播状态", - "login-show-live-on-bluesky-description": "直播时为您的 Bluesky 头像加上红色 LIVE 圆环,并允许 Streamplace 代您发布直播公告。取消勾选即可在不授予任何 Bluesky 账号访问权限的情况下登录。" -} diff --git a/js/i18n/public/locales/zh-Hans/settings.json b/js/i18n/public/locales/zh-Hans/settings.json deleted file mode 100644 index 9af46c5ca..000000000 --- a/js/i18n/public/locales/zh-Hans/settings.json +++ /dev/null @@ -1,246 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "下载新更新", - "check-for-updates": "检查更新", - "bundled-runtype": "捆绑版", - "ota-runtype": "空中下载 (OTA)", - "recovery-runtype": "复原模式", - "modal-latest-version": "您正在使用最新版本。", - "modal-no-update-available": "您已经在使用最新版本的 Streamplace,太棒了!", - "modal-update-available-title": "有可用更新", - "modal-update-available-description": "新版本的 Streamplace 已准备好下载", - "modal-update-failed": "更新检查失败。您可能需要透过 {$store} 更新应用程序。", - "modal-update-failed-title": "更新失败", - "modal-update-failed-description": "更新检查失败。您可能需要透过 {$store} 更新应用程序。", - "button-reload-app-on-update": "套用更新 (将重新加载应用程序)", - "use-custom-node": "使用自定义节点", - "default-url": "默认:{$url}", - "enter-custom-node-url": "输入自定义节点网址", - "save-button": "保存", - "try-new-web": "试用新版网页 (beta)", - "try-new-web-description": "切换到新的网页界面。你可以随时通过此设置切换回来。", - "try-old-app": "使用原版界面", - "try-old-app-description": "返回 Streamplace 原版界面。", - "language-selection": "语言", - "language-selection-description": "选择您偏好的语言", - "input-search-languages": "搜索语言...", - "help-translate": "帮助我们翻译 Streamplace", - "help-translate-description": "我们正在寻找志愿者协助将 Streamplace 翻译成更多语言。如果您有兴趣,请在 Discord 或 GitHub 上与我们联系!", - "currently-translating": "翻译正在进行中", - "currently-translating-description": "应用程序的某些部分可能看起来不完整。感谢您的耐心等待!", - "debug-recording-title": "允许 {$host} 录制您的直播串流以进行除错和服务改善", - "debug-recording-description": "可选项", - "manage-keys": "管理密钥", - "settings-title": "设置", - "error": "错误", - "about": "关于", - "account": "帐户", - "advanced": "高级", - "danmu": "弹幕", - "developer": "开发者", - "languages": "语言", - "privacy-security": "隐私与安全", - "streaming": "串流", - "notifications": "通知", - "cancel": "取消", - "create": "创建", - "delete": "删除", - "refresh": "刷新", - "sign-in": "登录", - "update": "更新", - "log-out": "登出", - "optional": "选填", - "account-greeting": "嗨,@{$handle}。", - "edit-profile-bluesky": "在 Bluesky 编辑个人数据", - "change-name-color": "更改名称颜色", - "key-management": "密钥管理", - "key-manager": "密钥管理器", - "your-stream-pubkeys": "您的串流公开密钥", - "no-keys": "尚未设置密钥", - "pubkey-description": "公开密钥与串流密钥 (用于串流软件) 配对以签署和验证您的串流", - "keys-count": "{$count} 个密钥", - "backup": "备份", - "backup-enabled": "S3 备份", - "backup-enabled-description": "自动将录影备份到兼容 S3 的存储", - "backup-connection-url": "连接网址", - "backup-connection-url-placeholder": "必须是格式为 s3+https://ACCESS_KEY:SECRET_KEY@region.endpoi.nt/bucket 的有效 S3 网址", - "backup-endpoint": "终端", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "存储桶", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "访问密钥", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "秘密密钥", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "backup-region": "地区", - "backup-region-placeholder": "us-east-1", - "backup-test-connection": "测试连接", - "backup-testing-connection": "正在测试连接...", - "backup-connection-successful": "连接成功", - "backup-connection-failed": "连接失败", - "show-password-in-url": "在网址中显示密码", - "show-password-in-url-description": "在连接网址中显示秘密密钥 (若有)", - "requested-seconds-per-segment": "每段秒数", - "requested-seconds-per-segment-description": "设置每段秒数,以请求服务器使用", - "recommendations": "推荐主播", - "manage-recommendations": "管理推荐主播", - "recommendations-to-others": "向他人推荐主播", - "recommendations-description": "向您的观众推荐最多 8 位主播", - "no-recommendations-yet": "尚未配置推荐", - "add-recommendation": "添加推荐", - "streamer-did": "主播 DID", - "recommendations-count": "{$count} 位推荐主播", - "webhooks": "Webhooks", - "webhook-integrations": "Webhook 集成", - "webhook-integrations-description": "连接外部服务以即时接收有关您串流的更新", - "create-webhook": "创建 Webhook", - "edit-webhook": "编辑 Webhook", - "delete-webhook": "删除 Webhook", - "no-webhooks-yet": "尚未设置 Webhook", - "failed-load-webhooks": "加载 Webhook 失败", - "webhook-will-no-longer-receive-events": "此 Webhook 将不再接收事件", - "create-first-webhook-description": "创建您的第一个 Webhook 以开始接收串流事件", - "example-captain-hook": "Hook 船长", - "webhooks-count": "{$count} 个 Webhook", - "activates-on": "触发于:", - "events": "事件", - "events-livestream": "直播串流事件", - "events-chat": "聊天事件", - "untitled-webhook": "未命名的 Webhook", - "inactive": "停用", - "active": "活动", - "multistream": "多重串流", - "multistream-targets": "多重串流目标", - "multistream-description": "自动将您的 Streamplace 直播推送到 Twitch 或 YouTube 等其他直播平台。", - "create-multistream-target": "创建多重串流目标", - "untitled-multistream-target": "未命名目标", - "failed-load-multistream-targets": "加载多重串流目标失败。请重试。", - "failed-toggle-multistream-target": "切换多重串流目标失败。请重试。", - "failed-delete-multistream-target": "删除多重串流目标失败。请重试。", - "no-multistream-targets-yet": "还没有目标!", - "multistream-targets-count": "{$count} 个目标", - "multistream-delete-target-confirmation": "您确定要删除“{$target}”吗?", - "this-action-cannot-be-undone": "此操作无法撤销。", - "rtmp-target-name": "RTMP 目标", - "rtmp-target-url": "RTMP 网址", - "rtmp-target-name-placeholder": "我的多重串流目标", - "multistream-create-target": "创建目标", - "multistream-edit-target": "编辑目标", - "created": "创建于", - "status": "状态", - "debug-recording": "调试录制", - "danmu-enabled": "启用弹幕", - "danmu-enabled-description": "将即时聊天消息以浮动评论的形式显示在您的屏幕上", - "danmu-opacity": "不透明度", - "danmu-speed": "速度", - "danmu-lane-count": "轨道数量", - "danmu-max-messages": "最大消息数", - "app-version-description": "当前没有可用的更新", - "confirm-delete": "您确定要删除吗?", - "action-cannot-be-undone": "此操作无法撤销", - "name-optional": "名称 (选填)", - "deleting": "正在删除...", - "saving": "正在保存...", - "go-to-dashboard": "前往仪表板", - "need-setup-live-dashboard": "需要先设置串流吗?请访问直播仪表板", - "no-languages-found": "找不到语言", - "branding": "品牌", - "branding-admin": "品牌管理", - "branding-admin-description": "自定义您的 Streamplace 实例。请注意,设置可能需要几小时才能生效。", - "branding-login-required": "请登录以管理品牌", - "branding-configuration": "配置", - "branding-text-settings": "文字设置", - "branding-colors": "颜色", - "branding-legal-links": "法律链接", - "branding-images": "图像", - "branding-broadcaster-did": "主播 DID", - "branding-broadcaster-did-description": "留空以使用服务器默认值", - "branding-site-title": "网站标题", - "branding-site-title-placeholder": "输入新网站标题", - "branding-site-description": "网站描述", - "branding-site-description-placeholder": "输入网站描述", - "branding-default-streamer": "默认主播", - "branding-default-streamer-none": "无", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "清除默认主播", - "branding-primary-color": "首要颜色", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "强调色", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "主标志", - "branding-main-logo-description": "SVG、PNG 或 JPEG (最大 500KB)", - "branding-favicon": "网站图标", - "branding-favicon-description": "SVG、PNG 或 ICO (最大 100KB)", - "branding-sidebar-bg": "侧边栏背景图片", - "branding-sidebar-bg-description": "SVG、PNG 或 JPEG (最大 500KB)——显示在侧边栏底部,全宽显示。为了获得最佳效果,请上传一张带有透明度的图片,因为目前没有单独的透明度选项。", - "branding-current": "当前:{$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "上传标志", - "branding-delete-logo": "删除标志", - "branding-upload-favicon": "上传网站图标", - "branding-delete-favicon": "删除网站图标", - "branding-upload-background": "上传背景", - "branding-delete-background": "删除背景", - "branding-web-only": "图像上传仅在网页端可用。", - "refresh-branding": "更新品牌资源", - "branding-add-legal-link": "添加法律链接", - "branding-edit-legal-link": "编辑法律链接", - "branding-legal-link-text-placeholder": "链接文本 (例如:隐私政策)", - "branding-legal-link-url-placeholder": "网址 (例如:https://example.com/privacy)", - "add": "添加", - "edit": "编辑", - "branding-not-authenticated": "请先登录", - "branding-empty-value": "请输入一个数值", - "branding-update-success": "{$key} 更新成功", - "branding-upload-success": "{$key} 上传成功", - "branding-delete-success": "{$key} 删除成功", - "branding-upload-failed": "上传失败", - "branding-delete-failed": "删除失败", - "branding-not-available": "文件上传仅在网页版上可用", - "node-legal-documents": "主播专属文档", - "backup-save": "保存备份设置", - "backup-saving": "正在保存备份设置...", - "backup-secret-key-set-placeholder": "(密码已设置)", - "backup-error-invalid-endpoint": "必须是有效的域名", - "backup-error-invalid-bucket": "必须只包含小写字母、数字、点和连字符", - "backup-error-invalid-segment-duration": "必须在 1 到 60 秒之间", - "backup-error-load-failed": "加载存储设置失败", - "backup-error-update-failed": "更新备份状态失败", - "backup-error-save-failed": "保存存储设置失败", - "backup-error-missing-secret": "没有秘密密钥时无法更新 S3 设置。请重新输入。", - "backup-segment-duration-placeholder": "6", - "badges": "徽章", - "badges-cosmetic-section": "装饰性徽章", - "badges-empty-state": "您还没有获得任何徽章。", - "badges-failed-load": "加载徽章失败", - "badges-failed-update": "更新徽章选择失败", - "badges-issued-by": "由 {$issuer} 颁发", - "badges-streamer-section": "主播徽章", - "issue-badges": "颁发徽章", - "issue-badges-back-to-definitions": "徽章定义", - "issue-badges-badge-name": "徽章名称", - "issue-badges-badge-name-placeholder": "例如 VIP、支持者", - "issue-badges-badge-type": "徽章类型", - "issue-badges-choose-image": "选择图片", - "issue-badges-create-definition": "创建徽章定义", - "issue-badges-create-definition-description": "定义可颁发给观众的新徽章类型", - "issue-badges-create-definition-subtitle": "定义新徽章类型", - "issue-badges-definition-created": "徽章定义已创建", - "issue-badges-description-optional": "描述(可选)", - "issue-badges-description-placeholder": "此徽章的可选描述", - "issue-badges-failed-create": "创建徽章定义失败", - "issue-badges-failed-issue": "颁发徽章失败", - "issue-badges-failed-load": "加载徽章定义失败", - "issue-badges-image-optional": "图片(可选)", - "issue-badges-image-too-large": "图片必须小于 256KB", - "issue-badges-image-web-only": "图片上传仅在网页版可用", - "issue-badges-issue-badge": "颁发徽章", - "issue-badges-issue-badge-description": "通过 DID 将\"{$name}\"徽章颁发给观众", - "issue-badges-issued": "徽章已颁发", - "issue-badges-issued-to": "已颁发给 {$did}", - "issue-badges-manage-description": "创建徽章定义并颁发给观众", - "issue-badges-recipient-did": "收件人 DID", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "点击颁发", - "issue-badges-your-definitions": "您的徽章定义" -} diff --git a/js/i18n/public/locales/zh-Hant/common.json b/js/i18n/public/locales/zh-Hant/common.json deleted file mode 100644 index e06e5aa8b..000000000 --- a/js/i18n/public/locales/zh-Hant/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "載入中...", - "error": "錯誤", - "cancel": "取消", - "confirm": "確認", - "close": "關閉", - "open": "開啟", - "ok": "確定", - "yes": "是", - "no": "否", - "continue": "繼續", - "back": "返回", - "next": "下一步", - "finish": "完成", - "save": "儲存", - "delete": "刪除", - "edit": "編輯", - "create": "建立", - "update": "更新", - "refresh": "重新整理", - "success": "成功", - "warning": "警告", - "info": "資訊", - "search-placeholder": "搜尋...", - "message-input": "輸入您的訊息...", - "please-log-in-to-access-this-page": "請登入以存取此頁面", - "go-to-settings": "前往設定", - "go-back": "返回", - "welcome-user": "歡迎,{$username}!", - "notification-count": "{$count ->\n [0] 無通知\n [1] 一則通知\n *[other] {$count} 則通知\n }", - "user-offline": "使用者離線", - "user-offline-message": "{$source ->\n [streamer] 看起來 <1>@{$handle} 離線 了,但他們推薦觀看:\n *[default] 看起來 <1>@{$handle} 離線 了,但我們推薦觀看:\n }", - "user-offline-no-recommendations": "看起來 <1>@{$handle} 離線 了。\n請稍後再來看看。", - "streaming-title": "正在直播 {$title}", - "viewer-count": "{$count ->\n [0] 0 位觀眾\n [1] 1 位觀眾\n *[other] {$count} 位觀眾\n }", - "pds-selector-title": "初次接觸 Atmosphere?", - "pds-selector-description": "您需要選擇一個 PDS(個人資料伺服器)來存取 Atmosphere 上的應用程式,例如 Bluesky、Tangled 和 Spark。", - "pds-selector-custom-label": "其他 PDS", - "pds-selector-custom-description": "輸入您自己的 PDS 主機網址", - "pds-selector-custom-url-label": "自訂 PDS 網址", - "pds-selector-custom-url-placeholder": "https://pds.example.com", - "pds-selector-learn-more": "了解更多關於自行架設的資訊", - "pds-selector-info": "每個主機都有自己的政策和可靠性標準。您的 ATProto 資料存放在您選擇的主機上,您可以之後進行遷移。注意:Streamplace 有自己的審核規則——無論您選擇哪個主機,都可能被 Streamplace 封禁。", - "pds-selector-read-policies": "在繼續之前,請閱讀 {$label} 的服務條款和隱私政策。", - "pds-selector-handle-policy-checkbox": "我已閱讀並同意識別碼政策", - "login-show-live-on-bluesky": "在 Bluesky 上顯示我的直播狀態", - "login-show-live-on-bluesky-description": "直播時為您的 Bluesky 頭像加上紅色 LIVE 圓環,並允許 Streamplace 代您發布直播公告。取消勾選即可在不授予任何 Bluesky 帳號存取權限的情況下登入。" -} diff --git a/js/i18n/public/locales/zh-Hant/settings.json b/js/i18n/public/locales/zh-Hant/settings.json deleted file mode 100644 index 5c5990c52..000000000 --- a/js/i18n/public/locales/zh-Hant/settings.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "app-version": "播家 v{$version}", - "download-new-update": "下載新更新", - "check-for-updates": "檢查更新", - "bundled-runtype": "捆綁版", - "ota-runtype": "空中下載 (OTA)", - "recovery-runtype": "復原模式", - "modal-latest-version": "您正在使用最新版本。", - "modal-no-update-available": "您已經在使用最新版本的直播地,太棒了!", - "modal-update-available-title": "有可用更新", - "modal-update-available-description": "新版本的直播地已準備好下載", - "modal-update-failed": "更新檢查失敗。您可能需要透過 {$store} 更新應用程式。", - "modal-update-failed-title": "更新失敗", - "modal-update-failed-description": "更新檢查失敗。您可能需要透過 {$store} 更新應用程式。", - "button-reload-app-on-update": "套用更新 (將重新載入應用程式)", - "use-custom-node": "使用自訂節點", - "default-url": "預設:{$url}", - "enter-custom-node-url": "輸入自訂節點網址", - "save-button": "儲存", - "try-new-web": "試用新版網頁 (beta)", - "try-new-web-description": "切換到新的網頁介面。你可以隨時透過此設定切換回來。", - "try-old-app": "使用原版介面", - "try-old-app-description": "返回 Streamplace 原版介面。", - "language-selection": "語言", - "language-selection-description": "選擇您偏好的語言", - "help-translate": "幫助我們翻譯播家", - "help-translate-description": "我們正在尋找志願者協助將播家翻譯成更多語言。如果您有興趣,請在 Discord 或 GitHub 上與我們聯繫!", - "currently-translating": "翻譯正在進行中", - "currently-translating-description": "應用程式的某些部分可能看起來不完整。感謝您的耐心等待!", - "debug-recording-title": "允許 {$host} 錄製您的直播串流以進行除錯和服務改善", - "debug-recording-description": "可選項目", - "input-search-languages": "搜尋語言...", - "manage-keys": "管理金鑰", - "settings-title": "設定", - "loading": "載入中...", - "error": "錯誤", - "cancel": "取消", - "confirm": "確認", - "welcome-user": "歡迎,{$username}!", - "notification-count": "{$count ->\n [0] 無通知\n [1] 一則通知\n *[other] {$count} 則通知\n }", - "search-placeholder": "搜尋...", - "message-input": "請輸入您的訊息...", - "success": "成功", - "warning": "警告", - "info": "資訊", - "close": "關閉", - "open": "開啟", - "delete": "刪除", - "edit": "編輯", - "create": "建立", - "update": "更新", - "refresh": "重新整理", - "save": "儲存", - "cancel-button": "取消", - "ok": "確定", - "yes": "是", - "no": "否", - "continue": "繼續", - "back": "返回", - "next": "下一步", - "finish": "完成", - "about": "關於", - "account": "帳戶", - "advanced": "進階", - "danmu": "彈幕", - "developer": "開發者", - "languages": "語言", - "privacy-security": "隱私與安全", - "streaming": "串流", - "notifications": "通知", - "sign-in": "登入", - "log-out": "登出", - "account-greeting": "嗨,@{$handle}。", - "edit-profile-bluesky": "編輯個人資料(在 Bluesky)", - "change-name-color": "變更名稱顏色", - "key-management": "金鑰管理", - "key-manager": "金鑰管理器", - "your-stream-pubkeys": "您的串流公開金鑰", - "no-keys": "尚未設定金鑰", - "pubkey-description": "公開金鑰與串流金鑰(用於串流軟體)配對以簽署和驗證您的串流", - "keys-count": "{$count} 個金鑰", - "webhooks": "Webhooks", - "webhook-integrations": "Webhook 整合", - "webhook-integrations-description": "連接外部服務以即時接收有關您串流的更新", - "create-webhook": "建立 Webhook", - "edit-webhook": "編輯 Webhook", - "delete-webhook": "刪除 Webhook", - "no-webhooks-yet": "尚未設定 webhook", - "failed-load-webhooks": "載入 webhook 失敗", - "webhook-will-no-longer-receive-events": "此 webhook 將不再接收事件", - "create-first-webhook-description": "建立您的第一個 webhook 以開始接收串流事件", - "example-captain-hook": "虎克船長", - "webhooks-count": "{$count ->\n *[other] {$count} 個 webhook\n }", - "activates-on": "觸發於:", - "events-livestream": "直播串流事件", - "events-chat": "聊天事件", - "untitled-webhook": "未命名的 Webhook", - "inactive": "停用", - "debug-recording": "除錯錄製", - "danmu-enabled": "啟用彈幕", - "danmu-enabled-description": "將即時聊天訊息以浮動評論的形式顯示在您的螢幕上", - "danmu-opacity": "不透明度", - "danmu-speed": "速度", - "danmu-lane-count": "軌道數量", - "danmu-max-messages": "最大訊息數", - "app-version-description": "目前版本資訊", - "confirm-delete": "您確定要刪除嗎?", - "action-cannot-be-undone": "此動作無法復原", - "name-optional": "名稱(選填)", - "deleting": "刪除中...", - "saving": "儲存中...", - "go-to-dashboard": "前往控制台", - "need-setup-live-dashboard": "您需要先在控制台中設定直播串流", - "no-languages-found": "找不到語言", - "backup-save": "儲存備份設定", - "backup-saving": "正在儲存備份設定...", - "backup-secret-key-set-placeholder": "(密碼已設定)", - "backup-error-invalid-endpoint": "必須是有效的網域名稱", - "backup-error-invalid-bucket": "只能包含小寫字母、數字、點和連字號", - "backup-error-invalid-segment-duration": "必須介於 1 到 60 秒之間", - "backup-error-load-failed": "無法載入儲存設定", - "backup-error-update-failed": "更新備份狀態失敗", - "backup-error-save-failed": "儲存設定失敗", - "backup-error-missing-secret": "無法在沒有金鑰的情況下更新 S3 設定。請重新輸入。", - "backup-segment-duration-placeholder": "6", - "backup": "備份", - "backup-enabled": "S3 備份", - "backup-enabled-description": "自動將錄影備份到相容 S3 的儲存空間", - "backup-connection-url": "連線網址", - "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", - "backup-endpoint": "端點", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "儲存桶", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "存取金鑰", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "秘密金鑰", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "show-password-in-url": "在網址中顯示密碼", - "show-password-in-url-description": "在連線網址中顯示秘密金鑰(若已輸入)", - "recommendations-to-others": "向他人推薦主播", - "recommendations-description": "向您的觀眾推薦最多 8 位主播", - "no-recommendations-yet": "尚未設定推薦", - "multistream": "多重串流", - "multistream-targets": "多重串流目標", - "multistream-description": "自動將您的 Streamplace 直播推送到 Twitch 或 YouTube 等其他直播平台。", - "create-multistream-target": "建立多重串流目標", - "untitled-multistream-target": "未命名目標", - "failed-load-multistream-targets": "載入多重串流目標失敗。請重試。", - "failed-toggle-multistream-target": "切換多重串流目標失敗。請重試。", - "no-multistream-targets-yet": "還沒有目標!", - "multistream-targets-count": "{$count} 個目標", - "multistream-delete-target-confirmation": "您確定要刪除「{$target}」嗎?", - "this-action-cannot-be-undone": "此動作無法復原。", - "rtmp-target-name": "RTMP 目標", - "rtmp-target-url": "RTMP 網址", - "rtmp-target-name-placeholder": "我的多重串流目標", - "multistream-create-target": "建立目標", - "multistream-edit-target": "編輯目標", - "created": "建立於", - "status": "狀態", - "branding": "品牌識別", - "branding-admin": "品牌識別管理", - "branding-admin-description": "自訂您的 Streamplace 執行個體。請注意,設定可能需要幾小時才能生效。", - "branding-login-required": "請登入以管理品牌識別", - "branding-configuration": "設定", - "branding-text-settings": "文字設定", - "branding-colors": "顏色", - "branding-legal-links": "法律連結", - "branding-images": "圖像", - "branding-broadcaster-did": "主播 DID", - "branding-broadcaster-did-description": "留空以使用伺服器預設值", - "branding-site-title": "網站標題", - "branding-site-title-placeholder": "輸入新網站標題", - "branding-site-description": "網站描述", - "branding-site-description-placeholder": "輸入網站描述", - "branding-default-streamer": "預設主播", - "branding-default-streamer-none": "無", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "清除預設主播", - "branding-primary-color": "主要顏色", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "強調色", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "主標誌", - "branding-main-logo-description": "SVG、PNG 或 JPEG(最大 500KB)", - "branding-favicon": "網站圖示", - "branding-favicon-description": "SVG、PNG 或 ICO(最大 100KB)", - "branding-sidebar-bg": "側邊欄背景圖片", - "branding-sidebar-bg-description": "SVG、PNG 或 JPEG(最大 500KB)——顯示在側邊欄底部,全寬顯示。為了最佳效果,請上傳帶有透明度的圖片,因為目前沒有單獨的透明度選項。", - "branding-current": "目前:{$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "上傳標誌", - "branding-delete-logo": "刪除標誌", - "branding-upload-favicon": "上傳網站圖示", - "branding-delete-favicon": "刪除網站圖示", - "branding-upload-background": "上傳背景", - "branding-delete-background": "刪除背景", - "branding-web-only": "圖片上傳僅在網頁版可用。", - "refresh-branding": "更新品牌識別資源", - "branding-add-legal-link": "新增法律連結", - "branding-edit-legal-link": "編輯法律連結", - "branding-legal-link-text-placeholder": "連結文字(例如:隱私政策)", - "branding-legal-link-url-placeholder": "網址(例如:https://example.com/privacy)", - "add": "新增", - "active": "啟用", - "optional": "選填", - "branding-not-authenticated": "請先登入", - "branding-empty-value": "請輸入一個值", - "branding-update-success": "{$key} 更新成功", - "branding-upload-success": "{$key} 上傳成功", - "branding-delete-success": "{$key} 刪除成功", - "branding-upload-failed": "上傳失敗", - "branding-delete-failed": "刪除失敗", - "branding-not-available": "檔案上傳僅在網頁版可用", - "node-legal-documents": "主播專屬文件", - "badges": "徽章", - "badges-cosmetic-section": "裝飾性徽章", - "badges-empty-state": "您尚未獲得任何徽章。", - "badges-failed-load": "載入徽章失敗", - "badges-failed-update": "更新徽章選擇失敗", - "badges-issued-by": "由 {$issuer} 頒發", - "badges-streamer-section": "主播徽章", - "issue-badges": "頒發徽章", - "issue-badges-back-to-definitions": "徽章定義", - "issue-badges-badge-name": "徽章名稱", - "issue-badges-badge-name-placeholder": "例如 VIP、支持者", - "issue-badges-badge-type": "徽章類型", - "issue-badges-choose-image": "選擇圖片", - "issue-badges-create-definition": "建立徽章定義", - "issue-badges-create-definition-description": "定義可頒發給觀眾的新徽章類型", - "issue-badges-create-definition-subtitle": "定義新徽章類型", - "issue-badges-definition-created": "徽章定義已建立", - "issue-badges-description-optional": "描述(選填)", - "issue-badges-description-placeholder": "此徽章的選填描述", - "issue-badges-failed-create": "建立徽章定義失敗", - "issue-badges-failed-issue": "頒發徽章失敗", - "issue-badges-failed-load": "載入徽章定義失敗", - "issue-badges-image-optional": "圖片(選填)", - "issue-badges-image-too-large": "圖片必須小於 256KB", - "issue-badges-image-web-only": "圖片上傳僅在網頁版可用", - "issue-badges-issue-badge": "頒發徽章", - "issue-badges-issue-badge-description": "透過 DID 將「{$name}」徽章頒發給觀眾", - "issue-badges-issued": "徽章已頒發", - "issue-badges-issued-to": "已頒發給 {$did}", - "issue-badges-manage-description": "建立徽章定義並頒發給觀眾", - "issue-badges-recipient-did": "收件者 DID", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "點擊頒發", - "issue-badges-your-definitions": "您的徽章定義" -} diff --git a/js/web/package.json b/js/web/package.json index 64495e137..399692310 100644 --- a/js/web/package.json +++ b/js/web/package.json @@ -6,15 +6,17 @@ "description": "Streamplace web app (Vite + TanStack Router). True-DOM, no React Native.", "scripts": { "dev": "vite --port 5173", - "predev": "pnpm gen:emoji", + "predev": "pnpm --filter @streamplace/i18n compile && pnpm gen:emoji", "build": "tsc --noEmit && vite build", "preview": "vite preview --port 5173", "check": "bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'", + "precheck": "pnpm --filter @streamplace/i18n compile", "gen:emoji": "node scripts/gen-emoji-data.mjs", - "prebuild": "pnpm gen:emoji", + "prebuild": "pnpm --filter @streamplace/i18n compile && pnpm gen:emoji", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "test": "vitest run", + "pretest": "pnpm --filter @streamplace/i18n compile", "test:watch": "vitest", "e2e": "npx playwright test", "e2e:ui": "npx playwright test --ui" diff --git a/js/web/public/locales/en-US/common.json b/js/web/public/locales/en-US/common.json deleted file mode 100644 index 2deeccfed..000000000 --- a/js/web/public/locales/en-US/common.json +++ /dev/null @@ -1,388 +0,0 @@ -{ - "loading": "Loading...", - "error": "Error", - "cancel": "Cancel", - "confirm": "Confirm", - "close": "Close", - "open": "Open", - "ok": "OK", - "yes": "Yes", - "no": "No", - "continue": "Continue", - "back": "Back", - "next": "Next", - "finish": "Finish", - "save": "Save", - "delete": "Delete", - "edit": "Edit", - "create": "Create", - "update": "Update", - "refresh": "Refresh", - "success": "Success", - "warning": "Warning", - "info": "Information", - "content-warning-badge": "Intended for certain audiences", - "content-warning-title": "Heads up!", - "content-warning-description": "This may contain:", - "search-placeholder": "Search...", - "search-for-streamers": "Search for streamers...", - "message-input": "Enter your message...", - "please-log-in-to-access-this-page": "Please log in to access this page", - "go-to-settings": "Go to Settings", - "go-back": "Go Back", - "sign-in": "Sign In", - "log-in": "Log in", - "sign-up": "Sign up", - "go-home": "Go home", - "try-again": "Try again", - "completing-sign-in": "Completing sign-in…", - "already-logged-in": "You're already logged in.", - "signed-in-as": "Signed in as @{$handle}", - "signed-in-as-code": "Signed in as {$handle}", - "sign-in-description": "Sign in with your Atmosphere account, such as your {$network} account.", - "handle-label": "Handle", - "redirecting": "Redirecting…", - "sign-in-failed": "Sign-in failed", - "welcome-user": "Welcome, {$username}!", - "notification-count": "{$count ->\n [0] No notifications\n [1] One notification\n *[other] {$count} notifications\n }", - "user-offline": "user is offline", - "user-offline-message": "{$source ->\n [streamer] Looks like <1>@{$handle} is offline, but they recommend checking out:\n *[default] Looks like <1>@{$handle} is offline, but we recommend checking out:\n }", - "user-offline-no-recommendations": "Looks like <1>@{$handle} is offline right now.\nCheck back later.", - "streaming-title": "streaming {$title}", - "viewer-count": "{$count ->\n [0] 0 viewers\n [1] 1 viewer\n *[other] {$count} viewers\n }", - "pds-selector-title": "New to the Atmosphere?", - "pds-selector-description": "Choose where your Atmosphere account is hosted. You can use it with {$network}, Streamplace, and other Atmosphere apps.", - "pds-selector-show-other-hosts": "See {$count} other account hosts", - "pds-selector-custom-label": "Another account host", - "pds-selector-custom-description": "Enter your own account host URL", - "pds-selector-custom-url-label": "Account host URL", - "pds-selector-custom-url-placeholder": "https://pds.example.com", - "pds-selector-learn-more": "Learn more about self-hosting", - "pds-selector-info": "Each host has their own policies and reliability standards. Your account data lives on the host you choose, and you can migrate later.", - "pds-selector-different-policies": "Streamplace has separate moderation policies. You can be banned from Streamplace regardless of which account host you choose.", - "pds-selector-read-policies": "Read {$label}'s Terms of Service and Privacy Policy before continuing.", - "pds-selector-handle-policy-checkbox": "I have read and agree to the handle policy", - "creator-not-found-title": "Creator not found", - "creator-not-found-description": "We couldn't find an Atmosphere account for @{$handle}.", - "creator-lookup-failed-title": "We couldn't load this creator", - "creator-lookup-failed-description": "The Atmosphere directory isn't responding right now. Try again in a moment.", - "profile": "Profile", - "more-results": "More results", - "nav-home": "Home", - "nav-videos": "Videos", - "nav-following": "Following", - "nav-account": "Account", - "chat-no-messages": "No messages yet", - "chat-scroll-to-top": "Scroll to top", - "chat-scroll-to-bottom": "Scroll to bottom", - "chat-reply-to-message": "Reply to message", - "chat-pin-message": "Pin message", - "chat-unpin-message": "Unpin message", - "chat-dismiss-pinned": "Dismiss pinned message", - "chat-view-profile-bluesky": "View profile on {$network}", - "chat-send-message": "Send a message", - "chat-replying-to": "Replying to {$handle}", - "chat-insert-emoji": "Insert emoji", - "chat-skin-tone": "Skin tone", - "chat-send-button": "Chat", - "chat-failed-send": "Failed to send message", - "chat-log-in-to": "Log in to chat", - "chat-pop-out": "Pop out chat", - "chat-close": "Close chat", - "chat-open": "Open chat", - "chat-moderation-menu": "Message options", - "chat-pin-until-stream-end": "Until stream end", - "chat-pin-duration-minutes": "{$count} min", - "chat-hide-message": "Hide message", - "chat-block-user": "Block user", - "chat-delete-message": "Delete message", - "chat-delete-confirm": "Are you sure? Click again to confirm.", - "streamer-fallback": "Streamer", - "view-profile": "View profile", - "moderation-error-title": "Moderation action failed", - "moderation-message-pinned": "Comment pinned", - "moderation-user-blocked": "{$handle} has been blocked from this channel", - "moderation-hide-failed": "Failed to hide message", - "moderation-block-failed": "Failed to block user", - "moderation-pin-failed": "Failed to pin message", - "moderation-delete-failed": "Failed to delete message", - "moderators-title": "Moderators", - "moderators-add": "Add", - "moderators-adding": "Adding…", - "moderators-loading": "Loading moderators…", - "moderators-empty": "No moderators yet", - "moderators-empty-hint": "Add moderators to help manage your chat", - "moderators-add-title": "Add Moderator", - "moderators-add-description": "Enter the DID or handle of the user you want to add as a moderator and select their permissions.", - "moderators-handle-label": "Moderator DID or handle", - "moderators-handle-placeholder": "handle.bsky.social or did:plc:…", - "moderators-permissions-label": "Permissions", - "moderators-permission-ban": "Ban users", - "moderators-permission-ban-desc": "Block users from chat", - "moderators-permission-hide": "Hide messages", - "moderators-permission-hide-desc": "Hide individual chat messages", - "moderators-permission-manage": "Manage livestream", - "moderators-permission-manage-desc": "Update stream title", - "moderators-permission-pin": "Pin messages", - "moderators-permission-pin-desc": "Pin and unpin chat messages", - "moderators-added-toast": "Moderator added", - "moderators-add-failed": "Failed to add moderator", - "moderators-remove": "Remove", - "moderators-remove-title": "Remove moderator?", - "moderators-remove-description": "This will revoke all moderation permissions for {$handle}.", - "moderators-removed-toast": "Moderator removed", - "moderators-remove-failed": "Failed to remove moderator", - "moderators-expired": "Expired", - "moderators-expires": "Expires {$date}", - "moderators-error-required": "Please enter a DID or handle", - "moderators-error-permissions": "Please select at least one permission", - "moderators-cancel": "Cancel", - "chat-report-message": "Report message…", - "report-live-stream": "Report Live Stream", - "report-something-else": "Report Something Else", - "report-title": "Report", - "report-description": "Why are you submitting this report?", - "report-reason-spam": "Spam", - "report-reason-spam-desc": "Excessive unwanted promotion, replies, mentions", - "report-reason-violation": "Rule Violation", - "report-reason-violation-desc": "Direct, blatant violation of laws or terms of service", - "report-reason-misleading": "Misleading Content", - "report-reason-misleading-desc": "Misleading identity, affiliation, or content", - "report-reason-sexual": "Sexual Content", - "report-reason-sexual-desc": "Unwanted or mislabeled sexual content", - "report-reason-rude": "Harassment", - "report-reason-rude-desc": "Rude, harassing, explicit, or otherwise unwelcoming behavior", - "report-reason-other": "Other", - "report-reason-other-desc": "Reports not falling under another report category", - "report-comments-label": "Additional comments (optional)", - "report-comments-placeholder": "Provide additional context for this report…", - "report-submit": "Submit", - "report-submitting": "Submitting…", - "report-submitted": "Report submitted", - "report-failed": "Failed to submit report", - "badge-moderator": "Moderator", - "badge-streamer": "Streamer", - "badge-vip": "VIP", - "badge-bot": "Bot", - "badge-event": "Event", - "badge-fallback": "Badge", - "badge-issued-by-streamplace": "Issued by Streamplace", - "badge-issued-by": "Issued by {$issuer}", - "badge-issued": "Issued", - "badge-self-labeled": "Self-labeled", - "badge-details": "Badge details", - "badge-back-to-profile": "Back to profile", - "badge-previous": "Previous badge", - "badge-next": "Next badge", - "badge-count": "Badge {$current} of {$total}", - "stream-details": "Stream details", - "edit-title": "Edit title", - "edit-title-dialog-title": "Edit stream title", - "edit-title-label": "Stream title", - "edit-title-saving": "Saving…", - "edit-title-saved": "Stream title updated", - "edit-title-failed": "Failed to update stream title", - "activity-events": "Events", - "activity-just-chatting": "Just Chatting", - "activity-music": "Music", - "activity-art": "Art", - "activity-software-dev": "Software Dev", - "activity-cooking": "Cooking", - "activity-miniatures": "Miniatures", - "activity-makers-crafting": "Makers & Crafting", - "activity-fitness": "Fitness", - "activity-sports": "Sports", - "watching-count": "{$count} watching", - "follow": "Follow", - "share-copy": "Copy", - "share-copied": "Copied", - "share-copy-link": "Copy Link", - "share-copy-embed-code": "Copy Embed Code", - "share-copy-embed-url": "Copy Embed URL", - "share-copy-failed": "Couldn’t copy that", - "share-video": "Share", - "share-video-message": "Watch {$title} on Streamplace", - "share-to-bluesky": "Share to {$network}", - "share-more-options": "More sharing options", - "share-failed": "Couldn’t open sharing options", - "like-video": "Like", - "unlike-video": "Unlike", - "like-update-failed": "Couldn’t update like", - "like-state-restored": "Your previous like state was restored.", - "more-actions": "More actions", - "video-tools": "Video tools", - "video-embed": "Embed", - "more-details": "More details", - "less-details": "Less details", - "offline": "Offline", - "live-badge": "Live", - "player-buffering": "Buffering", - "reconnecting": "Reconnecting…", - "stream-may-resume": "The stream may resume shortly.", - "stream-offline": "Stream offline", - "user-not-streaming": "{$user} is not currently streaming", - "stream-is-offline-title": "Stream is offline", - "user-not-streaming-check-back": "{$user} is not currently streaming. Check back later.", - "back-to-home": "Back to home", - "download-video": "Download video", - "untitled": "Untitled", - "default-stream-title": "A livestream!", - "pip-enter": "Picture-in-picture", - "pip-exit": "Exit picture-in-picture", - "theatre-enter": "Theatre mode", - "theatre-exit": "Exit theatre mode", - "teleporting-in": "Teleporting in", - "teleporting-to": "Teleporting to @{$handle}", - "views-count": "{$count ->\n [one] {$count} view\n *[other] {$count} views\n }", - "likes-count": "{$count ->\n [one] {$count} like\n *[other] {$count} likes\n }", - "could-not-load-streams": "Could not load streams. You might be offline.", - "live-now-count": "{$count ->\n [one] {$count} person live now\n *[other] {$count} people live now\n }", - "no-one-streaming": "No one is streaming right now", - "check-back-later": "Check back later?", - "hero-title": "The video layer for everything.", - "hero-description": "Streamplace is a streaming platform built on the AT Protocol.", - "learn-more": "Learn more", - "videos-title": "Videos", - "no-videos-yet": "No videos yet.", - "could-not-load-videos": "Couldn't load videos: {$error}", - "no-results-for": "No results for \"{$query}\"", - "find-streamers": "Find streamers by name or handle", - "search": "Search", - "search-failed": "Search failed", - "something-went-wrong": "Something went wrong", - "unexpected-error": "An unexpected error occurred.", - "upload-add-tag": "Add tag, press Enter", - "upload-add-thumbnail": "Add thumbnail image", - "upload-cancel": "Cancel", - "upload-choose-file": "Choose a video file", - "upload-choose-file-btn": "Choose file", - "upload-content-warnings": "Content Warnings", - "upload-delete": "Delete", - "upload-deleting": "Deleting...", - "upload-description": "Description", - "upload-description-placeholder": "Describe your video...", - "upload-details": "Details", - "upload-file": "File", - "upload-license": "License", - "upload-optional-info": "Optional · JPG, PNG up to 975KB", - "upload-preparing": "Preparing upload...", - "upload-processing": "Processing video", - "upload-publish": "Publish", - "upload-published": "Published", - "upload-publishing": "Publishing...", - "upload-publishing-btn": "Publishing...", - "upload-ready-publish": "Ready to publish", - "upload-status": "Status", - "upload-tags": "Tags", - "upload-thumbnail": "Thumbnail", - "upload-thumbnail-preview": "Thumbnail preview", - "upload-title": "Title", - "upload-title-placeholder": "Give your video a title", - "upload-update-video": "Update Video", - "upload-updating": "Updating...", - "upload-view-video": "View video →", - "upload-waiting-process": "Waiting to process...", - "create-key-description-delete-if-exposed": "If you think your stream key has been exposed, delete it immediately and create a new key.", - "create-key-description-staff": "We will never, ever, ask for your key.", - "create-key-do-not-share": "DO NOT SHARE THIS KEY, including showing it on stream. Anyone in possession of your key may be able to stream from your account.", - "create-key-title": "Are you sure about creating a new stream key?", - "create-stream-key": "Create Stream Key", - "stream-key": "Stream Key", - "stream-key-help": "Your stream key identifies you to Streamplace. You can have more than one stream key at a time, and it's a good idea to prune old keys that you aren't using via the key manager.", - "stream-key-info-description": "For security reasons, you won't be able to view it again through your account. If you lose this stream key, you'll need to generate a new one.", - "stream-key-label": "Stream Key", - "stream-key-save-now": "Please save this stream key somewhere safe.", - "allow-everyone-archive": "Allow everyone to archive your content indefinitely", - "allow-everyone-distribute": "Allow everyone to distribute your content", - "allowed-broadcasters": "Allowed Broadcasters", - "allowed-broadcasters-help": "Enter the DIDs of the broadcasters you want to allow, one per line.", - "content-rights": "Content Rights", - "content-rights-help": "Optional copyright and license information for your stream.", - "content-warnings": "Content Warnings", - "content-warnings-help": "You're required to flag your stream with themes that viewers may want a heads-up about.", - "copied-to-clipboard": "Copied to clipboard", - "copy-link": "Copy link", - "copyright-notice": "Copyright Notice", - "copyright-year": "Copyright Year", - "credit-line": "Credit Line", - "custom-license": "Custom…", - "custom-license-url": "Custom License URL/Text", - "delete-after": "Delete After", - "delete-after-help": "Duration in seconds (e.g. 300 for 5 minutes).", - "distribution": "Distribution", - "distribution-help": "Control who can redistribute your stream and for how long archives are kept.", - "editing-video": "Editing video", - "failed-to-create-key": "Failed to create key", - "go-to-video": "Go to video", - "key-manager": "Key Manager", - "license": "License", - "login-to-manage-videos": "Please log in to manage your videos.", - "login-to-upload": "Please log in to upload videos.", - "metadata": "Metadata", - "metadata-learn-more": "Learn more about content metadata", - "metadata-save-failed": "Failed to save metadata", - "metadata-saved": "Metadata saved", - "moderation": "Moderation", - "moderation-help": "Add/remove stream moderators. Moderators can hide chat messages and time out users in your chat.", - "nav-dashboard": "Dashboard", - "nav-settings": "Settings", - "no-videos-yet-upload": "No videos yet.", - "optional": "Optional", - "required": "Required", - "select-license": "Select a license…", - "login-show-live-on-bluesky": "Show when I'm live on {$network}", - "login-show-live-on-bluesky-description": "Gives your {$network} avatar the red LIVE ring while you stream and lets Streamplace post announcements for you. Uncheck to sign in without granting any access to your {$network} account.", - "player-play": "Play", - "player-pause": "Pause", - "player-mute": "Mute", - "player-unmute": "Unmute", - "player-seek": "Seek", - "player-settings": "Settings", - "player-fullscreen": "Fullscreen", - "player-exit-fullscreen": "Exit fullscreen", - "player-theatre": "Theatre mode", - "player-exit-theatre": "Exit theatre mode", - "player-pip": "Picture-in-picture", - "player-exit-pip": "Exit picture-in-picture", - "player-danmu": "Danmu", - "player-enable-danmu": "Enable danmu", - "player-disable-danmu": "Disable danmu", - "player-quality": "Quality", - "player-latency": "Latency", - "player-quality-auto": "Auto", - "player-stats": "Player stats", - "player-stats-resolution": "Resolution", - "player-stats-viewport": "Viewport", - "player-stats-bitrate": "Bitrate", - "player-stats-skipped": "Skipped", - "player-stats-buffer": "Buffer", - "player-stats-codecs": "Codecs", - "player-stats-protocol": "Protocol", - "player-stats-latency-mode": "Latency Mode", - "player-stats-render-surface": "Render Surface", - "player-stats-session": "Session", - "player-protocol-webrtc": "WebRTC", - "player-protocol-hls": "HLS", - "player-latency-low": "Low Latency", - "player-latency-standard": "Standard", - "player-error-network": "Network error", - "player-error-playback": "Playback error", - "player-error-format": "Stream format not supported", - "player-error-stream-not-live": "Stream not live", - "player-error-network-retrying": "Network error. Retrying.", - "player-error-media-recovering": "Media error. Recovering.", - "player-error-hls-unsupported": "Your browser doesn't support HLS playback.", - "player-error-stream-unavailable": "Stream unavailable. Stopped reconnecting.", - "player-error-sdp-failed": "Failed to create SDP offer", - "player-error-ice-failed": "Failed to gather ICE candidates", - "player-error-negotiation-failed": "WebRTC negotiation failed", - "player-error-stream-stalled": "Stream stalled. Reconnecting.", - "player-error-connection-lost": "Connection lost. Reconnecting.", - "upload-error-format": "File doesn't appear to be a supported video format (MP4, WebM, MKV, MOV, AVI, OGG, FLV, MPEG-TS).", - "upload-error-timeout": "Processing timed out. Please try again later.", - "upload-error-processing": "Processing failed", - "upload-error-not-logged-in": "Not logged in", - "upload-title-untitled": "Untitled", - "emoji-skin-tone": "Skin tone", - "emoji-search": "Search emoji…" -} diff --git a/js/web/public/locales/en-US/settings.json b/js/web/public/locales/en-US/settings.json deleted file mode 100644 index 1b664285a..000000000 --- a/js/web/public/locales/en-US/settings.json +++ /dev/null @@ -1,363 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "Download New Update", - "check-for-updates": "Check for Updates", - "bundled-runtype": "Bundled", - "ota-runtype": "Over-the-Air (OTA)", - "recovery-runtype": "Recovery Mode", - "modal-latest-version": "You are using the latest version.", - "modal-no-update-available": "You are on the latest version of Streamplace, hooray!", - "modal-update-available-title": "Update Available", - "modal-update-available-description": "A new version of Streamplace is ready to download", - "modal-update-failed": "Update check failed. You may need to update the app through the {$store}.", - "modal-update-failed-title": "Update Failed", - "modal-update-failed-description": "Update check failed. You may need to update the app through the {$store}.", - "button-reload-app-on-update": "Apply Update (will reload app)", - "use-custom-node": "Use Custom Node", - "default-url": "Default: {$url}", - "enter-custom-node-url": "Enter custom node URL", - "save-button": "Save", - "try-new-web": "Try the new web (beta)", - "try-new-web-description": "Switch to the new web interface. You can switch back anytime from this same setting.", - "try-old-app": "Use the original interface", - "try-old-app-description": "Go back to the original Streamplace interface.", - "language-selection": "Language", - "language-selection-description": "Choose your preferred language", - "input-search-languages": "Search languages...", - "help-translate": "Help us translate Streamplace", - "help-translate-description": "We're looking for volunteers to help translate Streamplace into more languages. If you're interested, please reach out to us on Discord or GitHub!", - "currently-translating": "Translations are on the way", - "currently-translating-description": "Some parts of the app may look incomplete. Thank you for your patience!", - "debug-recording-title": "Allow {$host} to record your livestream for debugging and improving the service", - "debug-recording-description": "Optional", - "livestream-recording-title": "Record my livestreams into VODs on {$host}", - "livestream-recording-description": "When enabled, your livestreams are saved so you can finalize and publish them as videos", - "auto-publish-vods-title": "Publish my livestreams as VODs automatically", - "auto-publish-vods-description": "When a recorded livestream ends, {$host} publishes it as a video right away, without waiting for you to finalize it", - "auto-publish-vods-stream-label": "Publish VOD automatically", - "auto-publish-vods-stream-description": "Publish recorded streams as VODs automatically. Applies to future streams too, until you turn it off.", - "manage-keys": "Manage Keys", - "settings-title": "Settings", - "error": "Error", - "about": "About", - "account": "Account", - "advanced": "Advanced", - "danmu": "Danmu", - "developer": "Developer", - "languages": "Languages", - "privacy-security": "Privacy & Security", - "streaming": "Streaming", - "notifications": "Notifications", - "notifications-title": "Push Notifications", - "notifications-web-description": "Get notified when streamers you follow go live.", - "notifications-mobile-description": "Push notifications are managed by your device settings.", - "notifications-blocked-description": "Blocked — you denied notification permission in your browser.", - "notifications-blocked-help": "To re-enable, update site permissions in your browser settings, then toggle this on again.", - "cancel": "Cancel", - "create": "Create", - "delete": "Delete", - "refresh": "Refresh", - "sign-in": "Sign In", - "update": "Update", - "log-out": "Log out", - "optional": "optional", - "account-greeting": "Hey, @{$handle}.", - "edit-profile-bluesky": "Edit profile (on {$network})", - "change-name-color": "Change name color", - "key-management": "Key Management", - "key-manager": "Key Manager", - "your-stream-pubkeys": "Your Stream Public Keys", - "no-keys": "No keys configured", - "pubkey-description": "Public keys are paired with stream keys (used in streaming software) to sign and verify your stream", - "keys-count": "{$count ->\n [one] {$count} key\n *[other] {$count} keys\n }", - "backup": "Backup", - "backup-enabled": "S3 Backup", - "backup-enabled-description": "Automatically backup recordings to S3-compatible storage", - "backup-connection-url": "Connection URL", - "backup-connection-url-placeholder": "Must be a valid S3 URL in the format s3+https://ACCESS_KEY:SECRET_KEY@region.endpoi.nt/bucket", - "backup-endpoint": "Endpoint", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "Bucket", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "Access Key", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "Secret Key", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "backup-region": "Region", - "backup-region-placeholder": "us-east-1", - "backup-test-connection": "Test Connection", - "backup-testing-connection": "Testing connection...", - "backup-connection-successful": "Connection successful", - "backup-connection-failed": "Failed to connect", - "show-password-in-url": "Show password in URL", - "show-password-in-url-description": "Display the secret key in the connection URL (if input)", - "requested-seconds-per-segment": "Seconds per segment", - "requested-seconds-per-segment-description": "Set the seconds per segment you wish to request the server use", - "recommendations": "Recommendations", - "manage-recommendations": "Manage Recommendations", - "recommendations-to-others": "Recommendations", - "recommendations-description": "Share up to 8 streamers you recommend to your viewers", - "no-recommendations-yet": "No recommendations configured yet", - "add-recommendation": "Add Recommendation", - "streamer-did": "Streamer DID", - "recommendations-count": "{$count ->\n [one] {$count} recommendation\n *[other] {$count} recommendations\n }", - "webhooks": "Webhooks", - "webhook-integrations": "Webhook Integrations", - "webhook-integrations-description": "Connect external services to receive real-time updates about your streams", - "create-webhook": "Create Webhook", - "edit-webhook": "Edit Webhook", - "delete-webhook": "Delete Webhook", - "no-webhooks-yet": "No webhooks configured yet", - "failed-load-webhooks": "Failed to load webhooks", - "webhook-will-no-longer-receive-events": "This webhook will no longer receive events", - "create-first-webhook-description": "Create your first webhook to start receiving stream events", - "example-captain-hook": "Captain Hook", - "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", - "activates-on": "Activates on:", - "events": "Events", - "events-livestream": "Livestream Events", - "events-chat": "Chat Events", - "events-stream-received": "Stream Received Events", - "untitled-webhook": "Untitled Webhook", - "inactive": "Inactive", - "active": "Active", - "multistream": "Multistreaming", - "multistream-targets": "Multistream Targets", - "multistream-description": "Automatically push your Streamplace livestreams to other streaming services like Twitch or YouTube.", - "create-multistream-target": "Create Multistream Target", - "untitled-multistream-target": "Untitled Target", - "failed-load-multistream-targets": "Failed to load multistream targets. Please try again.", - "failed-toggle-multistream-target": "Failed to toggle multistream target. Please try again.", - "failed-delete-multistream-target": "Failed to delete multistream target. Please try again.", - "no-multistream-targets-yet": "No targets yet!", - "no-multistream-destinations": "No multistream destinations yet", - "multistream-targets-count": "{$count ->\n [one] {$count} target\n *[other] {$count} targets\n }", - "multistream-delete-target-confirmation": "Are you sure you want to delete \"{$target}\"?", - "this-action-cannot-be-undone": "This action cannot be undone.", - "rtmp-target-name": "RTMP Target", - "rtmp-target-url": "RTMP URL", - "rtmp-target-name-placeholder": "My Multistream Target", - "multistream-create-target": "Create Target", - "multistream-edit-target": "Edit Target", - "created": "created", - "status": "status", - "debug-recording": "Debug Recording", - "danmu-enabled": "Enable Danmu", - "danmu-enabled-description": "Display live chat messages as floating comments on your screen", - "danmu-opacity": "Opacity", - "danmu-speed": "Speed", - "danmu-lane-count": "Lane Count", - "danmu-max-messages": "Max Messages", - "app-version-description": "No updates are available at this time", - "confirm-delete": "Are you sure you want to delete this?", - "action-cannot-be-undone": "This action cannot be undone", - "name-optional": "Name (optional)", - "deleting": "Deleting...", - "saving": "Saving...", - "go-to-dashboard": "Go to Dashboard", - "need-setup-live-dashboard": "Need to set up streaming first? Visit the live dashboard", - "no-languages-found": "No languages found", - "branding": "Branding", - "branding-admin": "Branding Administration", - "branding-admin-description": "Customize your Streamplace instance. Note that settings may take a few hours to propagate.", - "branding-login-required": "Please log in to manage branding", - "branding-configuration": "Configuration", - "branding-text-settings": "Text Settings", - "branding-colors": "Colors", - "branding-legal-links": "Legal Links", - "branding-bundle": "Export and import", - "branding-bundle-description": "A bundle is a zip with branding.yaml (everything set here, with the defaults listed for reference) plus the image files. Edit the YAML by hand or import it on another node.", - "branding-bundle-export": "Download bundle", - "branding-bundle-import": "Import bundle…", - "branding-bundle-merge": "Merge: keep settings the bundle doesn't mention (otherwise they are removed)", - "branding-bundle-preview": "Changes from {$name}", - "branding-bundle-no-changes": "Nothing would change.", - "branding-bundle-apply": "Apply", - "branding-bundle-imported": "Branding imported ({$count} keys)", - "branding-bundle-import-failed": "Import failed", - "branding-bundle-export-failed": "Export failed", - "branding-bundle-invalid": "That bundle can't be imported", - "branding-navigation": "Navigation", - "branding-chat-layout": "Chat message layout", - "branding-chat-layout-description": "Compact is one line per message with the time and handle. Avatar shows each message as a post: avatar, display name, handle and age above the text.", - "branding-chat-compact": "Compact", - "branding-chat-avatar": "Avatar", - "branding-chat-name-colors": "Chat name colors", - "branding-chat-name-colors-description": "Show display names in each user's chosen chat color, or in the default text color.", - "branding-chat-name-colors-on": "User colors", - "branding-chat-name-colors-off": "Default color", - "branding-chat-badges": "Chat badges", - "branding-chat-badges-description": "Badges beside names in chat. All shows everything; Custom only shows badges issued from your badge definitions and hides the built-in streamer, moderator and bot marks; None hides every badge. Verified checks are separate.", - "branding-chat-badges-all": "All", - "branding-chat-badges-custom": "Custom only", - "branding-chat-badges-none": "None", - "branding-nav-links": "Navigation links", - "branding-nav-links-description": "Replace the sidebar's browse and creator sections with your own links: a JSON list of objects with label, url and an optional icon (home, search, play, live, bell, message, hash, list, bookmark, user, settings, video, book). Paths starting with / navigate inside the app; other URLs open as links.", - "branding-nav-cta": "Navigation button", - "branding-nav-cta-description": "A highlighted button under the navigation links: JSON with label and url.", - "branding-app-banner": "Mobile app suggestion", - "branding-app-banner-description": "On phones, the web app suggests the Streamplace mobile app: a banner in the page and, on iOS, the App Store's Smart App Banner. Turn it off for a node that is its own product.", - "branding-app-banner-on": "Suggest the app", - "branding-app-banner-off": "Off", - "branding-social-links": "Social links", - "branding-social-links-description": "The row of icon links at the bottom of the sidebar. The first field is its heading; the second is a JSON list of objects with label, url and icon, where icon is bluesky, discord, one of the navigation icon names, or socialIcon1 to socialIcon4 for an uploaded icon. Unset shows the Streamplace links; Hide removes the row.", - "branding-social-heading-placeholder": "Say Hello?", - "branding-social-hide": "Hide", - "branding-upload": "Upload", - "branding-remove": "Remove", - "branding-social-icons-description": "Uploaded icons, referenced from the list above by slot name. SVG preferred, 24 x 24, max 64KB; an SVG drawn with currentColor takes the sidebar's icon color.", - "branding-theme": "Theme", - "branding-theme-description": "Page background and text color, one pair per color scheme; the app derives its raised surfaces, muted text and hairline borders from them. Hex colors (#rrggbb). Accent and status colors are separate settings above. Leave a value unset to use the default, or set only the background and the text color is chosen to read on it.", - "branding-background-dark": "Page background (dark mode)", - "branding-foreground-dark": "Text color (dark mode)", - "branding-background-light": "Page background (light mode)", - "branding-foreground-light": "Text color (light mode)", - "branding-reset": "Reset", - "branding-default": "default", - "branding-status-colors": "Status Colors", - "branding-status-colors-description": "Colors for errors, success, warnings, informational notes and the live badge. Hex colors (#rrggbb); text on top of them is chosen automatically.", - "branding-danger-color": "Danger / error", - "branding-success-color": "Success", - "branding-warning-color": "Warning", - "branding-info-color": "Info", - "branding-live-color": "Live badge", - "branding-images": "Images", - "branding-broadcaster-did": "Broadcaster DID", - "branding-broadcaster-did-description": "Leave empty to use server default", - "branding-site-title": "Site Title", - "branding-site-title-placeholder": "Enter new site title", - "branding-site-description": "Site Description", - "branding-site-description-placeholder": "Enter site description", - "branding-link-cards": "Link cards", - "branding-link-cards-description": "What a shared link shows for each page: the stream page, the front page (live tab), the videos tab, a video page and the go-live tab. Templates take {} (the site name below, or the site title), and on pages about one account {} (their display name, else handle) and {}. Descriptions of stream and video pages are their post text.", - "branding-network-name": "Network name", - "branding-network-name-description": "What the app calls the social network viewers sign in with and share to: \"Sign in with your {$network} account\", \"Share to {$network}\", the live-ring permission. Default Bluesky.", - "branding-network-profile-url": "Profile link", - "branding-network-profile-url-description": "Where a chat user's profile link goes, from the profile card and the message menu. Use {} and {} as placeholders. Default is the Bluesky profile page.", - "branding-network-icon-description": "Icon shown on those profile links. SVG preferred, max 64KB; unset shows the Bluesky butterfly.", - "branding-verified-icon-description": "Badge shown beside verified users. SVG preferred, max 64KB, currentColor takes the primary color; unset shows a check in the primary color.", - "branding-login-placeholder": "Login handle example", - "branding-login-placeholder-description": "The example handle shown in the login form's empty handle field, e.g. you.example.com.", - "branding-default-streamer": "Default Streamer", - "branding-default-streamer-none": "None", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-default-video": "Default video", - "branding-default-video-description": "While set, the front page shows this video instead of the default streamer's stream: the replay to send everyone to after an event. Its at:// URI, or its page path (handle/video/rkey). Clear it to return the front page to the streamer.", - "branding-default-video-placeholder": "at://did:plc:.../place.stream.video/... or handle/video/rkey", - "branding-clear-default-video": "Clear default video", - "branding-clear-default-streamer": "Clear Default Streamer", - "branding-primary-color": "Primary Color", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Accent Color (secondary surfaces, highlights)", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "Main Logo", - "branding-main-logo-description": "SVG, PNG, or JPEG (max 500KB)", - "branding-favicon": "Default favicon", - "branding-favicon-light": "Light mode favicon", - "branding-favicon-dark": "Dark mode favicon", - "branding-favicon-description": "SVG, PNG, or ICO (max 100KB). Used when an override is absent or the client does not support theme selection. An icon with a background works well as the default.", - "branding-favicon-override-description": "SVG, PNG, or ICO (max 100KB). Overrides the default for the browser or OS appearance, not the app theme. Transparent backgrounds are supported.", - "branding-sidebar-bg": "Sidebar Background Image", - "branding-sidebar-bg-description": "SVG, PNG, or JPEG (max 500kb) - appears aligned to bottom of sidebar, full width. Upload an image with opacity for best results, as there is not currently a separate opacity option.", - "branding-current": "Current: {$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "Upload Logo", - "branding-delete-logo": "Delete Logo", - "branding-upload-favicon": "Upload Favicon", - "branding-delete-favicon": "Delete Favicon", - "branding-upload-background": "Upload Background", - "branding-link-banner": "Link Preview Image", - "branding-link-banner-description": "The image shown when the site's link is shared (OpenGraph / Twitter card). PNG, JPEG or WebP, 1200 x 630 recommended, max 2MB. The card's title and description come from the Site Title and Site Description above.", - "branding-upload-link-banner": "Upload Image", - "branding-delete-link-banner": "Remove Image", - "branding-delete-background": "Delete Background", - "branding-web-only": "Image uploads are only available on web.", - "refresh-branding": "Refresh branding assets", - "branding-add-legal-link": "Add Legal Link", - "branding-edit-legal-link": "Edit Legal Link", - "branding-legal-link-text-placeholder": "Link text (e.g., Privacy Policy)", - "branding-legal-link-url-placeholder": "URL (e.g., https://example.com/privacy)", - "add": "Add", - "edit": "Edit", - "branding-not-authenticated": "Please log in first", - "branding-empty-value": "Please enter a value", - "branding-update-success": "{$key} updated successfully", - "branding-upload-success": "{$key} uploaded successfully", - "branding-delete-success": "{$key} deleted successfully", - "branding-upload-failed": "Failed to upload", - "branding-delete-failed": "Failed to delete", - "branding-not-available": "File uploads are only available on web", - "node-legal-documents": "Broadcaster-specific Documents", - "backup-save": "Save backup settings", - "backup-saving": "Saving backup settings...", - "backup-secret-key-set-placeholder": "(Password already set)", - "backup-error-invalid-endpoint": "Must be a valid domain name", - "backup-error-invalid-bucket": "Must contain only lowercase letters, numbers, dots, and hyphens", - "backup-error-invalid-segment-duration": "Must be between 1 and 60 seconds", - "backup-error-load-failed": "Failed to load storage settings", - "backup-error-update-failed": "Failed to update backup status", - "backup-error-save-failed": "Failed to save storage settings", - "backup-error-missing-secret": "Cannot update S3 settings without the secret key. Please re-enter it.", - "backup-segment-duration-placeholder": "6", - "badges": "Badges", - "badges-cosmetic-section": "Cosmetic Badges", - "badges-empty-state": "You haven't been issued any badges yet.", - "badges-failed-load": "Failed to load badges", - "badges-failed-update": "Failed to update badge selection", - "badges-issued-by": "Issued by {$issuer}", - "badges-streamer-section": "Streamer Badges", - "issue-badges": "Issue Badges", - "issue-badges-back-to-definitions": "Badge Definitions", - "issue-badges-badge-name": "Badge Name", - "issue-badges-badge-name-placeholder": "e.g. VIP, Supporter", - "issue-badges-badge-type": "Badge Type", - "issue-badges-choose-image": "Choose Image", - "issue-badges-create-definition": "Create Badge Definition", - "issue-badges-create-definition-description": "Define a new badge type that can be issued to viewers", - "issue-badges-create-definition-subtitle": "Define a new badge type", - "issue-badges-definition-created": "Badge definition created", - "issue-badges-description-optional": "Description (optional)", - "issue-badges-description-placeholder": "Optional description for this badge", - "issue-badges-failed-create": "Failed to create badge definition", - "issue-badges-failed-issue": "Failed to issue badge", - "issue-badges-failed-load": "Failed to load badge definitions", - "issue-badges-image-optional": "Image (optional)", - "issue-badges-image-too-large": "Image must be under 256KB", - "issue-badges-image-web-only": "Image upload is only available on web", - "issue-badges-issue-badge": "Issue Badge", - "issue-badges-issue-badge-description": "Issue the \"{$name}\" badge to a viewer by their DID", - "issue-badges-issued": "Badge issued", - "issue-badges-issued-to": "Issued to {$did}", - "issue-badges-manage-description": "Create badge definitions and issue them to viewers", - "issue-badges-recipient-did": "Recipient DID", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "Tap to issue", - "issue-badges-your-definitions": "Your Badge Definitions", - "chat-profile": "Chat Profile", - "chat-profile-login-required": "Please log in to edit your chat profile.", - "chat-profile-saved": "Chat profile saved", - "chat-profile-save-failed": "Failed to save chat profile", - "chat-profile-name-color": "Name Color", - "chat-profile-name-color-description": "Choose the color of your name in chat.", - "chat-profile-preview-message": "Hello, world!", - "chat-profile-self-labels": "Self Labels", - "chat-profile-label-bot": "Bot", - "chat-profile-label-bot-description": "Mark your chat messages as coming from a bot.", - "control-panel": "Control Panel", - "create-stream-key-in-stream-settings": "Create a stream key in Stream Settings", - "creator-settings": "Creator Settings", - "delete-all-keys": "Delete All Keys", - "delete-all-keys-btn": "Delete All", - "delete-all-keys-description": "This will permanently remove all {$count} stream keys. Any streaming software configured with these keys will stop working.", - "delete-all-keys-title": "Delete all stream keys?", - "failed-delete-keys": "Failed to delete {$count} key(s)", - "failed-to-delete-key": "Failed to delete key", - "my-videos": "My Videos", - "stream-settings": "Stream Settings", - "stream-settings-link": "Stream Settings", - "time-ago-days": "{$days}d ago", - "time-ago-hours": "{$hours}h ago", - "time-ago-minutes": "{$minutes}m ago", - "time-ago-seconds": "{$seconds}s ago", - "upload-videos": "Upload Videos" -} diff --git a/js/web/public/locales/es-ES/common.json b/js/web/public/locales/es-ES/common.json deleted file mode 100644 index c4448a6fc..000000000 --- a/js/web/public/locales/es-ES/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "Cargando...", - "error": "Error", - "cancel": "Cancelar", - "confirm": "Confirmar", - "close": "Cerrar", - "open": "Abrir", - "ok": "OK", - "yes": "Sí", - "no": "No", - "continue": "Continuar", - "back": "Atrás", - "next": "Siguiente", - "finish": "Finalizar", - "save": "Guardar", - "delete": "Eliminar", - "edit": "Editar", - "create": "Crear", - "update": "Actualizar", - "refresh": "Actualizar", - "success": "Éxito", - "warning": "Advertencia", - "info": "Información", - "search-placeholder": "Buscar...", - "message-input": "Escribe tu mensaje...", - "please-log-in-to-access-this-page": "Por favor, inicia sesión para acceder a esta página", - "go-to-settings": "Ir a Configuración", - "go-back": "Volver", - "welcome-user": "¡Bienvenido, {$username}!", - "notification-count": "{$count ->\n [0] Sin notificaciones\n [1] Una notificación\n *[other] {$count} notificaciones\n }", - "user-offline": "usuario desconectado", - "user-offline-message": "{$source ->\n [streamer] Parece que <1>@{$handle} está desconectado, pero ellos recomiendan ver:\n *[default] Parece que <1>@{$handle} está desconectado, pero te recomendamos ver:\n }", - "user-offline-no-recommendations": "Parece que <1>@{$handle} está desconectado ahora mismo.\nVuelve más tarde.", - "streaming-title": "transmitiendo {$title}", - "viewer-count": "{$count ->\n [0] 0 espectadores\n [1] 1 espectador\n *[other] {$count} espectadores\n }", - "pds-selector-title": "¿Nuevo en Atmosphere?", - "pds-selector-description": "Necesitarás seleccionar un PDS (Servidor de Datos Personal) para acceder a apps en Atmosphere, como Bluesky, Tangled y Spark.", - "pds-selector-custom-label": "Otro PDS", - "pds-selector-custom-description": "Introduce la URL de tu propio servidor PDS", - "pds-selector-custom-url-label": "URL de PDS personalizado", - "pds-selector-custom-url-placeholder": "https://pds.ejemplo.com", - "pds-selector-learn-more": "Más información sobre el autoalojamiento", - "pds-selector-info": "Cada servidor tiene sus propias políticas y estándares de fiabilidad. Tus datos ATProto viven en el servidor que elijas y puedes migrar más tarde. Nota: Streamplace tiene sus propias reglas de moderación; puedes ser expulsado de Streamplace independientemente del servidor que elijas.", - "pds-selector-read-policies": "Lee los Términos de Servicio y la Política de Privacidad de {$label} antes de continuar.", - "pds-selector-handle-policy-checkbox": "He leído y acepto la política de identificadores", - "login-show-live-on-bluesky": "Mostrar cuando estoy en directo en Bluesky", - "login-show-live-on-bluesky-description": "Añade el anillo rojo LIVE a tu avatar de Bluesky mientras transmites y permite que Streamplace publique anuncios por ti. Desmárcalo para iniciar sesión sin conceder ningún acceso a tu cuenta de Bluesky." -} diff --git a/js/web/public/locales/es-ES/settings.json b/js/web/public/locales/es-ES/settings.json deleted file mode 100644 index cda42b357..000000000 --- a/js/web/public/locales/es-ES/settings.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "Descargar Nueva Actualización", - "check-for-updates": "Buscar Actualizaciones", - "bundled-runtype": "Empaquetado", - "ota-runtype": "Over-the-Air (OTA)", - "recovery-runtype": "Modo de Recuperación", - "modal-latest-version": "Estás usando la última versión.", - "modal-no-update-available": "¡Tienes la versión más reciente de Streamplace, genial!", - "modal-update-available-title": "Actualización Disponible", - "modal-update-available-description": "Una nueva versión de Streamplace está lista para descargar", - "modal-update-failed": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar\nla aplicación a través de {$store}.", - "modal-update-failed-title": "Actualización Falló", - "modal-update-failed-description": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar\nla aplicación a través de {$store}.", - "button-reload-app-on-update": "Aplicar Actualización (recargará la aplicación)", - "use-custom-node": "Usar Nodo Personalizado", - "default-url": "Predeterminado: {$url}", - "enter-custom-node-url": "Introduce la URL del nodo personalizado", - "save-button": "Guardar", - "try-new-web": "Probar la nueva web (beta)", - "try-new-web-description": "Cambia a la nueva interfaz web. Puedes volver en cualquier momento desde este mismo ajuste.", - "try-old-app": "Usar la interfaz original", - "try-old-app-description": "Vuelve a la interfaz original de Streamplace.", - "language-selection": "Idioma", - "language-selection-description": "Elige tu idioma preferido", - "help-translate": "Ayúdanos a traducir Streamplace", - "help-translate-description": "Buscamos voluntarios para ayudar a traducir Streamplace a más idiomas. Si te interesa, contáctanos en Discord o GitHub!", - "currently-translating": "Las traducciones están en camino", - "currently-translating-description": "Algunas partes de la aplicación pueden verse incompletas. ¡Gracias por tu paciencia!", - "debug-recording-title": "Permitir que {$host} grabe tu retransmisión en directo para depuración y mejora del servicio", - "debug-recording-description": "Opcional", - "input-search-languages": "Buscar idiomas...", - "manage-keys": "Gestionar Claves", - "settings-title": "Configuración", - "loading": "Cargando...", - "error": "Error", - "cancel": "Cancelar", - "confirm": "Confirmar", - "welcome-user": "¡Bienvenido, {$username}!", - "notification-count": "{$count ->\n [0] Sin notificaciones\n [1] Una notificación\n *[other] {$count} notificaciones\n }", - "search-placeholder": "Buscar...", - "message-input": "Introduce tu mensaje...", - "success": "Éxito", - "warning": "Aviso", - "info": "Información", - "close": "Cerrar", - "open": "Abrir", - "delete": "Eliminar", - "edit": "Editar", - "create": "Crear", - "update": "Actualizar", - "refresh": "Actualizar", - "save": "Guardar", - "cancel-button": "Cancelar", - "ok": "Aceptar", - "yes": "Sí", - "no": "No", - "continue": "Continuar", - "back": "Volver", - "next": "Siguiente", - "finish": "Finalizar", - "about": "Acerca de", - "account": "Cuenta", - "advanced": "Avanzado", - "danmu": "Danmu", - "developer": "Desarrollador", - "languages": "Idiomas", - "privacy-security": "Privacidad y Seguridad", - "streaming": "Transmisión", - "notifications": "Notificaciones", - "sign-in": "Iniciar Sesión", - "log-out": "Cerrar sesión", - "account-greeting": "Hola, @{$handle}.", - "edit-profile-bluesky": "Editar perfil (en Bluesky)", - "change-name-color": "Cambiar color de nombre", - "key-management": "Gestión de Claves", - "key-manager": "Gestor de Claves", - "your-stream-pubkeys": "Tus Claves Públicas de Transmisión", - "no-keys": "No hay claves configuradas", - "pubkey-description": "Las claves públicas se emparejan con claves de transmisión (usadas en software de transmitiendo) para firmar y verificar tu transmisión", - "keys-count": "{$count ->\n [one] {$count} clave\n [many] {$count} claves\n *[other] {$count} claves\n }", - "webhooks": "Webhooks", - "webhook-integrations": "Integraciones de Webhook", - "webhook-integrations-description": "Conecta servicios externos para recibir actualizaciones en tiempo real sobre tus transmisiones", - "create-webhook": "Crear Webhook", - "edit-webhook": "Editar Webhook", - "delete-webhook": "Eliminar Webhook", - "no-webhooks-yet": "Aún no hay webhooks configurados", - "failed-load-webhooks": "Error al cargar webhooks", - "webhook-will-no-longer-receive-events": "Este webhook ya no recibirá eventos", - "create-first-webhook-description": "Crea tu primer webhook para empezar a recibir eventos de transmisión", - "example-captain-hook": "Capitán Garfio", - "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", - "activates-on": "Activa en:", - "events-livestream": "Eventos de Transmisión en Directo", - "events-chat": "Eventos de Chat", - "untitled-webhook": "Webhook Sin Título", - "inactive": "Inactivo", - "debug-recording": "Grabación de Depuración", - "danmu-enabled": "Habilitar Danmu", - "danmu-enabled-description": "Muestra mensajes de chat en vivo como comentarios flotantes en tu pantalla", - "danmu-opacity": "Opacidad", - "danmu-speed": "Velocidad", - "danmu-lane-count": "Número de Carriles", - "danmu-max-messages": "Mensajes Máximos", - "app-version-description": "Información de la versión actual", - "confirm-delete": "¿Estás seguro de que quieres eliminar esto?", - "action-cannot-be-undone": "Esta acción no se puede deshacer", - "name-optional": "Nombre (opcional)", - "deleting": "Eliminando...", - "saving": "Guardando...", - "go-to-dashboard": "Ir al Panel", - "need-setup-live-dashboard": "Primero necesitas configurar una transmisión en directo en el panel", - "no-languages-found": "No se encontraron idiomas", - "backup-save": "Guardar ajustes de copia de seguridad", - "backup-saving": "Guardando ajustes de copia de seguridad...", - "backup-secret-key-set-placeholder": "(Contraseña ya establecida)", - "backup-error-invalid-endpoint": "Debe ser un nombre de dominio válido", - "backup-error-invalid-bucket": "Debe contener solo letras minúsculas, números, puntos y guiones", - "backup-error-invalid-segment-duration": "Debe estar entre 1 y 60 segundos", - "backup-error-load-failed": "Error al cargar los ajustes de almacenamiento", - "backup-error-update-failed": "Error al actualizar el estado de la copia de seguridad", - "backup-error-save-failed": "Error al guardar los ajustes de almacenamiento", - "backup-error-missing-secret": "No se pueden actualizar los ajustes de S3 sin la clave secreta. Por favor, vuelva a introducirla.", - "backup-segment-duration-placeholder": "6", - "backup": "Copia de seguridad", - "backup-enabled": "S3 Backup", - "backup-enabled-description": "Respaldar automáticamente las grabaciones en almacenamiento compatible con S3", - "backup-connection-url": "URL de conexión", - "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", - "backup-endpoint": "Punto de conexión", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "Contenedor", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "Clave de acceso", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "Clave secreta", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "show-password-in-url": "Mostrar contraseña en la URL", - "show-password-in-url-description": "Mostrar la clave secreta en la URL de conexión (si se introduce)", - "recommendations-to-others": "Recomendaciones a otros", - "recommendations-description": "Comparte hasta 8 streamers que recomiendas a tus espectadores", - "no-recommendations-yet": "No hay recomendaciones configuradas todavía", - "multistream": "Multi-transmisión", - "multistream-targets": "Objetivos de multi-transmisión", - "multistream-description": "Envía automáticamente tus transmisiones en directo de Streamplace a otros servicios de streaming como Twitch o YouTube.", - "create-multistream-target": "Crear objetivo de multi-transmisión", - "untitled-multistream-target": "Objetivo sin título", - "failed-load-multistream-targets": "Error al cargar los objetivos de multi-transmisión. Por favor, inténtalo de nuevo.", - "failed-toggle-multistream-target": "Error al cambiar el estado del objetivo de multi-transmisión. Por favor, inténtalo de nuevo.", - "no-multistream-targets-yet": "¡Aún no hay objetivos!", - "multistream-targets-count": "{$count ->\n [one] {$count} objetivo\n [many] {$count} objetivos\n *[other] {$count} objetivos\n }", - "multistream-delete-target-confirmation": "¿Estás seguro de que quieres eliminar \"{$target}\"?", - "this-action-cannot-be-undone": "Esta acción no se puede deshacer.", - "rtmp-target-name": "Objetivo RTMP", - "rtmp-target-url": "URL de RTMP", - "rtmp-target-name-placeholder": "Mi objetivo de multi-transmisión", - "multistream-create-target": "Crear objetivo", - "multistream-edit-target": "Editar objetivo", - "created": "creado", - "status": "estado", - "branding": "Branding", - "branding-admin": "Administración de branding", - "branding-admin-description": "Personaliza tu instancia de Streamplace. Ten en cuenta que la configuración puede tardar varias horas en propagarse.", - "branding-login-required": "Por favor, inicia sesión para gestionar el branding", - "branding-configuration": "Configuración", - "branding-text-settings": "Configuración de texto", - "branding-colors": "Colores", - "branding-legal-links": "Vínculos legales", - "branding-images": "Imágenes", - "branding-broadcaster-did": "DID del radiodifusor", - "branding-broadcaster-did-description": "Dejar vacío para usar el valor predeterminado del servidor", - "branding-site-title": "Título del sitio", - "branding-site-title-placeholder": "Introduce un nuevo título de sitio", - "branding-site-description": "Descripción del sitio", - "branding-site-description-placeholder": "Introduce la descripción del sitio", - "branding-default-streamer": "Streamer predeterminado", - "branding-default-streamer-none": "Ninguno", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "Quitar streamer predeterminado", - "branding-primary-color": "Color principal", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Color de acento", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "Logo principal", - "branding-main-logo-description": "SVG, PNG o JPEG (máx. 500KB)", - "branding-favicon": "Favicon", - "branding-favicon-description": "SVG, PNG o ICO (máx. 100KB)", - "branding-sidebar-bg": "Imagen de fondo de la barra lateral", - "branding-sidebar-bg-description": "SVG, PNG o JPEG (máx. 500kb) - aparece alineada en la parte inferior de la barra lateral, a todo el ancho. Sube una imagen con opacidad para mejores resultados, ya que actualmente no hay una opción de opacidad separada.", - "branding-current": "Actual: {$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "Subir logo", - "branding-delete-logo": "Eliminar logo", - "branding-upload-favicon": "Subir favicon", - "branding-delete-favicon": "Eliminar favicon", - "branding-upload-background": "Subir imagen de fondo", - "branding-delete-background": "Eliminar imagen de fondo", - "branding-web-only": "La subida de imágenes solo está disponible en la web.", - "refresh-branding": "Actualizar recursos de branding", - "branding-add-legal-link": "Agregar vínculo legal", - "branding-edit-legal-link": "Editar vínculo legal", - "branding-legal-link-text-placeholder": "Texto del vínculo (ej., Política de Privacidad)", - "branding-legal-link-url-placeholder": "URL (ej., https://ejemplo.com/privacidad)", - "add": "Agregar", - "active": "Activo", - "optional": "opcional", - "branding-not-authenticated": "Por favor, inicia sesión primero", - "branding-empty-value": "Por favor, introduce un valor", - "branding-update-success": "{$key} actualizado correctamente", - "branding-upload-success": "{$key} subido correctamente", - "branding-delete-success": "{$key} eliminado correctamente", - "branding-upload-failed": "Error al subir", - "branding-delete-failed": "Error al eliminar", - "branding-not-available": "La subida de archivos solo está disponible en la web", - "node-legal-documents": "Documentos específicos del radiodifusor", - "badges": "Insignias", - "badges-cosmetic-section": "Insignias cosméticas", - "badges-empty-state": "Aún no te han otorgado ninguna insignia.", - "badges-failed-load": "Error al cargar las insignias", - "badges-failed-update": "Error al actualizar la selección de insignias", - "badges-issued-by": "Otorgada por {$issuer}", - "badges-streamer-section": "Insignias del streamer", - "issue-badges": "Emitir insignias", - "issue-badges-back-to-definitions": "Definiciones de insignias", - "issue-badges-badge-name": "Nombre de la insignia", - "issue-badges-badge-name-placeholder": "ej. VIP, Colaborador", - "issue-badges-badge-type": "Tipo de insignia", - "issue-badges-choose-image": "Elegir imagen", - "issue-badges-create-definition": "Crear definición de insignia", - "issue-badges-create-definition-description": "Define un nuevo tipo de insignia que se puede otorgar a los espectadores", - "issue-badges-create-definition-subtitle": "Define un nuevo tipo de insignia", - "issue-badges-definition-created": "Definición de insignia creada", - "issue-badges-description-optional": "Descripción (opcional)", - "issue-badges-description-placeholder": "Descripción opcional para esta insignia", - "issue-badges-failed-create": "Error al crear la definición de insignia", - "issue-badges-failed-issue": "Error al emitir la insignia", - "issue-badges-failed-load": "Error al cargar las definiciones de insignias", - "issue-badges-image-optional": "Imagen (opcional)", - "issue-badges-image-too-large": "La imagen debe ser menor de 256KB", - "issue-badges-image-web-only": "La subida de imágenes solo está disponible en la web", - "issue-badges-issue-badge": "Emitir insignia", - "issue-badges-issue-badge-description": "Emite la insignia \"{$name}\" a un espectador mediante su DID", - "issue-badges-issued": "Insignia emitida", - "issue-badges-issued-to": "Emitida a {$did}", - "issue-badges-manage-description": "Crea definiciones de insignias y emítelas a los espectadores", - "issue-badges-recipient-did": "DID del destinatario", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "Toca para emitir", - "issue-badges-your-definitions": "Tus definiciones de insignias" -} diff --git a/js/web/public/locales/fr-FR/common.json b/js/web/public/locales/fr-FR/common.json deleted file mode 100644 index 1a6809ae6..000000000 --- a/js/web/public/locales/fr-FR/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "Chargement...", - "error": "Erreur", - "cancel": "Annuler", - "confirm": "Confirmer", - "close": "Fermer", - "open": "Ouvrir", - "ok": "OK", - "yes": "Oui", - "no": "Non", - "continue": "Continuer", - "back": "Retour", - "next": "Suivant", - "finish": "Terminer", - "save": "Enregistrer", - "delete": "Supprimer", - "edit": "Modifier", - "create": "Créer", - "update": "Mettre à jour", - "refresh": "Actualiser", - "success": "Succès", - "warning": "Avertissement", - "info": "Information", - "search-placeholder": "Rechercher...", - "message-input": "Entrez votre message...", - "please-log-in-to-access-this-page": "Veuillez vous connecter pour accéder à cette page", - "go-to-settings": "Aller aux Paramètres", - "go-back": "Retour", - "welcome-user": "Bienvenue, {$username} !", - "notification-count": "{$count ->\n [0] Aucune notification\n [1] Une notification\n *[other] {$count} notifications\n }", - "user-offline": "utilisateur hors ligne", - "user-offline-message": "{$source ->\n [streamer] On dirait que <1>@{$handle} est hors ligne, mais ils recommandent de regarder :\n *[default] On dirait que <1>@{$handle} est hors ligne, mais nous recommandons de regarder :\n }", - "user-offline-no-recommendations": "On dirait que <1>@{$handle} est hors ligne maintenant.\nRevenez plus tard.", - "streaming-title": "diffusion de {$title}", - "viewer-count": "{$count ->\n [0] 0 spectateurs\n [1] 1 spectateur\n *[other] {$count} spectateurs\n }", - "pds-selector-title": "Nouveau sur Atmosphere ?", - "pds-selector-description": "Vous devrez sélectionner un PDS (Serveur de Données Personnel) pour accéder aux applications sur Atmosphere, comme Bluesky, Tangled et Spark.", - "pds-selector-custom-label": "Un autre PDS", - "pds-selector-custom-description": "Saisissez l'URL de votre propre hôte PDS", - "pds-selector-custom-url-label": "URL PDS personnalisée", - "pds-selector-custom-url-placeholder": "https://pds.exemple.com", - "pds-selector-learn-more": "En savoir plus sur l'auto-hébergement", - "pds-selector-info": "Chaque hôte a ses propres politiques et normes de fiabilité. Vos données ATProto se trouvent sur l'hôte que vous choisissez et vous pouvez migrer ultérieurement. Remarque : Streamplace a ses propres règles de modération — vous pouvez être banni de Streamplace quel que soit l'hôte choisi.", - "pds-selector-read-policies": "Lisez les Conditions d'utilisation et la Politique de confidentialité de {$label} avant de continuer.", - "pds-selector-handle-policy-checkbox": "J'ai lu et j'accepte la politique des identifiants", - "login-show-live-on-bluesky": "Afficher quand je suis en direct sur Bluesky", - "login-show-live-on-bluesky-description": "Ajoute l'anneau rouge LIVE à votre avatar Bluesky pendant vos streams et permet à Streamplace de publier des annonces pour vous. Décochez la case pour vous connecter sans accorder aucun accès à votre compte Bluesky." -} diff --git a/js/web/public/locales/fr-FR/settings.json b/js/web/public/locales/fr-FR/settings.json deleted file mode 100644 index 4cdf81ac7..000000000 --- a/js/web/public/locales/fr-FR/settings.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "Télécharger la nouvelle mise à jour", - "check-for-updates": "Vérifier les mises à jour", - "bundled-runtype": "Inclus", - "ota-runtype": "Over-the-Air (OTA)", - "recovery-runtype": "Mode de récupération", - "modal-latest-version": "Vous utilisez la dernière version.", - "modal-no-update-available": "Vous avez la dernière version de Streamplace, génial !", - "modal-update-available-title": "Mise à jour disponible", - "modal-update-available-description": "Une nouvelle version de Streamplace est prête à télécharger", - "modal-update-failed": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via {$store}.", - "modal-update-failed-title": "Échec de la mise à jour", - "modal-update-failed-description": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via {$store}.", - "button-reload-app-on-update": "Appliquer la mise à jour (l'application va se recharger)", - "use-custom-node": "Utiliser un nœud personnalisé", - "default-url": "Par défaut : {$url}", - "enter-custom-node-url": "Saisir l'URL du nœud personnalisé", - "save-button": "Enregistrer", - "try-new-web": "Essayer la nouvelle web (bêta)", - "try-new-web-description": "Passez à la nouvelle interface web. Vous pouvez revenir à tout moment depuis ce même réglage.", - "try-old-app": "Utiliser l'interface d'origine", - "try-old-app-description": "Revenir à l'interface d'origine de Streamplace.", - "language-selection": "Langue", - "language-selection-description": "Choisissez votre langue préférée", - "help-translate": "Aidez-nous à traduire Streamplace", - "help-translate-description": "Nous recherchons des bénévoles pour aider à traduire Streamplace dans plus de langues. Si vous êtes intéressé, contactez-nous sur Discord ou GitHub !", - "currently-translating": "Les traductions sont en cours", - "currently-translating-description": "Certaines parties de l'application peuvent sembler incomplètes. Merci pour votre patience !", - "debug-recording-title": "Autoriser {$host} à enregistrer votre diffusion en direct pour le débogage et l'amélioration du service", - "debug-recording-description": "Optionnel", - "input-search-languages": "Rechercher des langues...", - "manage-keys": "Gérer les Clés", - "settings-title": "Paramètres", - "loading": "Chargement...", - "error": "Erreur", - "cancel": "Annuler", - "confirm": "Confirmer", - "welcome-user": "Bienvenue, {$username} !", - "notification-count": "{$count ->\n [0] Aucune notification\n [1] Une notification\n *[other] {$count} notifications\n }", - "search-placeholder": "Rechercher...", - "message-input": "Saisissez votre message...", - "success": "Succès", - "warning": "Attention", - "info": "Information", - "close": "Fermer", - "open": "Ouvrir", - "delete": "Supprimer", - "edit": "Modifier", - "create": "Créer", - "update": "Mettre à jour", - "refresh": "Actualiser", - "save": "Enregistrer", - "cancel-button": "Annuler", - "ok": "OK", - "yes": "Oui", - "no": "Non", - "continue": "Continuer", - "back": "Retour", - "next": "Suivant", - "finish": "Terminer", - "about": "À propos", - "account": "Compte", - "advanced": "Avancé", - "danmu": "Danmu", - "developer": "Développeur", - "languages": "Langues", - "privacy-security": "Confidentialité et Sécurité", - "streaming": "Diffusion", - "notifications": "Notifications", - "sign-in": "Se connecter", - "log-out": "Se déconnecter", - "account-greeting": "Salut, @{$handle}.", - "edit-profile-bluesky": "Modifier le profil (sur Bluesky)", - "change-name-color": "Changer la couleur du nom", - "key-management": "Gestion des Clés", - "key-manager": "Gestionnaire de Clés", - "your-stream-pubkeys": "Vos Clés Publiques de Diffusion", - "no-keys": "Aucune clé configurée", - "pubkey-description": "Les clés publiques sont associées aux clés de diffusion (utilisées dans le logiciel de streaming) pour signer et vérifier votre diffusion", - "keys-count": "{$count ->\n [one] {$count} clé\n [many] {$count} clés\n *[other] {$count} clés\n }", - "webhooks": "Webhooks", - "webhook-integrations": "Intégrations de Webhook", - "webhook-integrations-description": "Connectez des services externes pour recevoir des mises à jour en temps réel sur vos diffusions", - "create-webhook": "Créer un Webhook", - "edit-webhook": "Modifier le Webhook", - "delete-webhook": "Supprimer le Webhook", - "no-webhooks-yet": "Aucun webhook configuré pour le moment", - "failed-load-webhooks": "Échec du chargement des webhooks", - "webhook-will-no-longer-receive-events": "Ce webhook ne recevra plus d'événements", - "create-first-webhook-description": "Créez votre premier webhook pour commencer à recevoir des événements de diffusion", - "example-captain-hook": "Capitaine Crochet", - "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", - "activates-on": "S'active sur :", - "events-livestream": "Événements de Diffusion en Direct", - "events-chat": "Événements de Chat", - "untitled-webhook": "Webhook Sans Titre", - "inactive": "Inactif", - "debug-recording": "Enregistrement de Débogage", - "danmu-enabled": "Activer Danmu", - "danmu-enabled-description": "Afficher les messages de chat en direct sous forme de commentaires flottants sur votre écran", - "danmu-opacity": "Opacité", - "danmu-speed": "Vitesse", - "danmu-lane-count": "Nombre de Voies", - "danmu-max-messages": "Messages Maximum", - "app-version-description": "Informations sur la version actuelle", - "confirm-delete": "Êtes-vous sûr de vouloir supprimer ceci ?", - "action-cannot-be-undone": "Cette action ne peut pas être annulée", - "name-optional": "Nom (optionnel)", - "deleting": "Suppression...", - "saving": "Enregistrement...", - "go-to-dashboard": "Aller au Tableau de Bord", - "need-setup-live-dashboard": "Vous devez d'abord configurer une diffusion en direct dans le tableau de bord", - "no-languages-found": "Aucune langue trouvée", - "backup-save": "Enregistrer les paramètres de sauvegarde", - "backup-saving": "Enregistrement des paramètres de sauvegarde...", - "backup-secret-key-set-placeholder": "(Mot de passe déjà défini)", - "backup-error-invalid-endpoint": "Doit être un nom de domaine valide", - "backup-error-invalid-bucket": "Doit contenir uniquement des lettres minuscules, des chiffres, des points et des traits d'union", - "backup-error-invalid-segment-duration": "Doit être compris entre 1 et 60 secondes", - "backup-error-load-failed": "Échec du chargement des paramètres de stockage", - "backup-error-update-failed": "Échec de la mise à jour de l'état de la sauvegarde", - "backup-error-save-failed": "Échec de l'enregistrement des paramètres de stockage", - "backup-error-missing-secret": "Impossible de mettre à jour les paramètres S3 sans la clé secrète. Veuillez la saisir à nouveau.", - "backup-segment-duration-placeholder": "6", - "backup": "Sauvegarde", - "backup-enabled": "S3 Backup", - "backup-enabled-description": "Sauvegarder automatiquement les enregistrements dans un stockage compatible S3", - "backup-connection-url": "URL de connexion", - "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", - "backup-endpoint": "Point de terminaison", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "Compartiment", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "Clé d'accès", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "Clé secrète", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "show-password-in-url": "Afficher le mot de passe dans l'URL", - "show-password-in-url-description": "Afficher la clé secrète dans l'URL de connexion (si saisie)", - "recommendations-to-others": "Recommandations aux autres", - "recommendations-description": "Partagez jusqu'à 8 streameurs que vous recommandez à vos spectateurs", - "no-recommendations-yet": "Aucune recommandation configurée pour le moment", - "multistream": "Multidiffusion", - "multistream-targets": "Cibles de multidiffusion", - "multistream-description": "Envoyez automatiquement vos diffusions en direct Streamplace vers d'autres services comme Twitch ou YouTube.", - "create-multistream-target": "Créer une cible de multidiffusion", - "untitled-multistream-target": "Cible sans titre", - "failed-load-multistream-targets": "Échec du chargement des cibles de multidiffusion. Veuillez réessayer.", - "failed-toggle-multistream-target": "Échec du basculement de la cible de multidiffusion. Veuillez réessayer.", - "no-multistream-targets-yet": "Aucune cible pour le moment !", - "multistream-targets-count": "{$count ->\n [one] {$count} cible\n [many] {$count} cibles\n *[other] {$count} cibles\n }", - "multistream-delete-target-confirmation": "Êtes-vous sûr de vouloir supprimer \"{$target}\" ?", - "this-action-cannot-be-undone": "Cette action ne peut pas être annulée.", - "rtmp-target-name": "Cible RTMP", - "rtmp-target-url": "URL RTMP", - "rtmp-target-name-placeholder": "Ma cible de multidiffusion", - "multistream-create-target": "Créer la cible", - "multistream-edit-target": "Modifier la cible", - "created": "créé", - "status": "statut", - "branding": "Image de marque", - "branding-admin": "Administration de l'image de marque", - "branding-admin-description": "Personnalisez votre instance Streamplace. Notez que les paramètres peuvent prendre quelques heures à se propager.", - "branding-login-required": "Veuillez vous connecter pour gérer l'image de marque", - "branding-configuration": "Configuration", - "branding-text-settings": "Paramètres de texte", - "branding-colors": "Couleurs", - "branding-legal-links": "Liens légaux", - "branding-images": "Images", - "branding-broadcaster-did": "DID du diffuseur", - "branding-broadcaster-did-description": "Laisser vide pour utiliser la valeur par défaut du serveur", - "branding-site-title": "Titre du site", - "branding-site-title-placeholder": "Saisir un nouveau titre de site", - "branding-site-description": "Description du site", - "branding-site-description-placeholder": "Saisir la description du site", - "branding-default-streamer": "Streameur par défaut", - "branding-default-streamer-none": "Aucun", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "Effacer le streameur par défaut", - "branding-primary-color": "Couleur principale", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Couleur d'accent", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "Logo principal", - "branding-main-logo-description": "SVG, PNG ou JPEG (max 500 Ko)", - "branding-favicon": "Favicon", - "branding-favicon-description": "SVG, PNG ou ICO (max 100 Ko)", - "branding-sidebar-bg": "Image d'arrière-plan de la barre latérale", - "branding-sidebar-bg-description": "SVG, PNG ou JPEG (max 500 ko) - apparaît aligné en bas de la barre latérale, pleine largeur. Téléchargez une image avec opacité pour de meilleurs résultats, car il n'existe pas actuellement d'option d'opacité séparée.", - "branding-current": "Actuel : {$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "Télécharger le logo", - "branding-delete-logo": "Supprimer le logo", - "branding-upload-favicon": "Télécharger le favicon", - "branding-delete-favicon": "Supprimer le favicon", - "branding-upload-background": "Télécharger l'arrière-plan", - "branding-delete-background": "Supprimer l'arrière-plan", - "branding-web-only": "Les téléchargements d'images ne sont disponibles que sur le web.", - "refresh-branding": "Actualiser les ressources de l'image de marque", - "branding-add-legal-link": "Ajouter un lien légal", - "branding-edit-legal-link": "Modifier le lien légal", - "branding-legal-link-text-placeholder": "Texte du lien (ex., Politique de confidentialité)", - "branding-legal-link-url-placeholder": "URL (ex., https://exemple.com/confidentialite)", - "add": "Ajouter", - "active": "Actif", - "optional": "optionnel", - "branding-not-authenticated": "Veuillez d'abord vous connecter", - "branding-empty-value": "Veuillez saisir une valeur", - "branding-update-success": "{$key} mis à jour avec succès", - "branding-upload-success": "{$key} téléchargé avec succès", - "branding-delete-success": "{$key} supprimé avec succès", - "branding-upload-failed": "Échec du téléchargement", - "branding-delete-failed": "Échec de la suppression", - "branding-not-available": "Les téléchargements de fichiers ne sont disponibles que sur le web", - "node-legal-documents": "Documents spécifiques au diffuseur", - "badges": "Badges", - "badges-cosmetic-section": "Badges cosmétiques", - "badges-empty-state": "Vous n'avez pas encore reçu de badges.", - "badges-failed-load": "Échec du chargement des badges", - "badges-failed-update": "Échec de la mise à jour de la sélection de badges", - "badges-issued-by": "Délivré par {$issuer}", - "badges-streamer-section": "Badges du streameur", - "issue-badges": "Délivrer des badges", - "issue-badges-back-to-definitions": "Définitions de badges", - "issue-badges-badge-name": "Nom du badge", - "issue-badges-badge-name-placeholder": "ex. VIP, Supporter", - "issue-badges-badge-type": "Type de badge", - "issue-badges-choose-image": "Choisir une image", - "issue-badges-create-definition": "Créer une définition de badge", - "issue-badges-create-definition-description": "Définissez un nouveau type de badge qui peut être délivré aux spectateurs", - "issue-badges-create-definition-subtitle": "Définir un nouveau type de badge", - "issue-badges-definition-created": "Définition de badge créée", - "issue-badges-description-optional": "Description (optionnelle)", - "issue-badges-description-placeholder": "Description optionnelle pour ce badge", - "issue-badges-failed-create": "Échec de la création de la définition de badge", - "issue-badges-failed-issue": "Échec de la délivrance du badge", - "issue-badges-failed-load": "Échec du chargement des définitions de badges", - "issue-badges-image-optional": "Image (optionnelle)", - "issue-badges-image-too-large": "L'image doit faire moins de 256 Ko", - "issue-badges-image-web-only": "Le téléchargement d'images n'est disponible que sur le web", - "issue-badges-issue-badge": "Délivrer un badge", - "issue-badges-issue-badge-description": "Délivrez le badge \"{$name}\" à un spectateur par son DID", - "issue-badges-issued": "Badge délivré", - "issue-badges-issued-to": "Délivré à {$did}", - "issue-badges-manage-description": "Créez des définitions de badges et délivrez-les aux spectateurs", - "issue-badges-recipient-did": "DID du destinataire", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "Appuyer pour délivrer", - "issue-badges-your-definitions": "Vos définitions de badges" -} diff --git a/js/web/public/locales/pt-BR/common.json b/js/web/public/locales/pt-BR/common.json deleted file mode 100644 index 348dd8f80..000000000 --- a/js/web/public/locales/pt-BR/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "Carregando...", - "error": "Erro", - "cancel": "Cancelar", - "confirm": "Confirmar", - "close": "Fechar", - "open": "Abrir", - "ok": "OK", - "yes": "Sim", - "no": "Não", - "continue": "Continuar", - "back": "Voltar", - "next": "Próximo", - "finish": "Concluir", - "save": "Salvar", - "delete": "Excluir", - "edit": "Editar", - "create": "Criar", - "update": "Atualizar", - "refresh": "Atualizar", - "success": "Sucesso", - "warning": "Aviso", - "info": "Informação", - "search-placeholder": "Pesquisar...", - "message-input": "Digite sua mensagem...", - "please-log-in-to-access-this-page": "Por favor, faça login para acessar esta página", - "go-to-settings": "Ir para Configurações", - "go-back": "Voltar", - "welcome-user": "Bem-vindo, {$username}!", - "notification-count": "{$count ->\n [0] Nenhuma notificação\n [1] Uma notificação\n *[other] {$count} notificações\n }", - "user-offline": "usuário offline", - "user-offline-message": "{$source ->\n [streamer] Parece que <1>@{$handle} está offline, mas eles recomendam assistir:\n *[default] Parece que <1>@{$handle} está offline, mas recomendamos assistir:\n }", - "user-offline-no-recommendations": "Parece que <1>@{$handle} está offline agora.\nVolte mais tarde.", - "streaming-title": "transmitindo {$title}", - "viewer-count": "{$count ->\n [0] 0 espectadores\n [1] 1 espectador\n *[other] {$count} espectadores\n }", - "pds-selector-title": "Novo no Atmosphere?", - "pds-selector-description": "Você precisará selecionar um PDS (Servidor de Dados Pessoal) para acessar apps no Atmosphere, como Bluesky, Tangled e Spark.", - "pds-selector-custom-label": "Outro PDS", - "pds-selector-custom-description": "Digite a URL do seu próprio host PDS", - "pds-selector-custom-url-label": "URL do PDS personalizado", - "pds-selector-custom-url-placeholder": "https://pds.exemplo.com", - "pds-selector-learn-more": "Saiba mais sobre auto-hospedagem", - "pds-selector-info": "Cada host tem suas próprias políticas e padrões de confiabilidade. Seus dados ATProto ficam no host que você escolher e você pode migrar depois. Nota: O Streamplace tem suas próprias regras de moderação — você pode ser banido do Streamplace independentemente do host escolhido.", - "pds-selector-read-policies": "Leia os Termos de Serviço e a Política de Privacidade de {$label} antes de continuar.", - "pds-selector-handle-policy-checkbox": "Li e concordo com a política de identificadores", - "login-show-live-on-bluesky": "Mostrar quando estou ao vivo no Bluesky", - "login-show-live-on-bluesky-description": "Adiciona o anel vermelho LIVE ao seu avatar do Bluesky enquanto você transmite e permite que o Streamplace publique anúncios por você. Desmarque para entrar sem conceder nenhum acesso à sua conta do Bluesky." -} diff --git a/js/web/public/locales/pt-BR/settings.json b/js/web/public/locales/pt-BR/settings.json deleted file mode 100644 index f0d956b57..000000000 --- a/js/web/public/locales/pt-BR/settings.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "Baixar Nova Atualização", - "check-for-updates": "Verificar Atualizações", - "bundled-runtype": "Empacotado", - "ota-runtype": "Over-the-Air (OTA)", - "recovery-runtype": "Modo de Recuperação", - "modal-latest-version": "Você está usando a versão mais recente.", - "modal-no-update-available": "Você está na versão mais recente do Streamplace, eba!", - "modal-update-available-title": "Atualização Disponível", - "modal-update-available-description": "Uma nova versão do Streamplace está pronta para download", - "modal-update-failed": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do {$store}.", - "modal-update-failed-title": "Atualização Falhou", - "modal-update-failed-description": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do {$store}.", - "button-reload-app-on-update": "Aplicar Atualização (o aplicativo será recarregado)", - "use-custom-node": "Usar Nó Personalizado", - "default-url": "Padrão: {$url}", - "enter-custom-node-url": "Digite a URL do nó personalizado", - "save-button": "Salvar", - "try-new-web": "Experimentar a nova web (beta)", - "try-new-web-description": "Mude para a nova interface web. Você pode voltar a qualquer momento nesta mesma configuração.", - "try-old-app": "Usar a interface original", - "try-old-app-description": "Volte para a interface original do Streamplace.", - "language-selection": "Idioma", - "language-selection-description": "Escolha seu idioma preferido", - "help-translate": "Ajude-nos a traduzir o Streamplace", - "help-translate-description": "Estamos procurando voluntários para ajudar a traduzir o Streamplace para mais idiomas. Se você estiver interessado, entre em contato conosco no Discord ou GitHub!", - "currently-translating": "As traduções estão a caminho", - "currently-translating-description": "Algumas partes do aplicativo podem parecer incompletas. Obrigado pela sua paciência!", - "debug-recording-title": "Permitir que {$host} grave sua transmissão ao vivo para depuração e melhoria do serviço", - "debug-recording-description": "Opcional", - "input-search-languages": "Pesquisar idiomas...", - "manage-keys": "Gerenciar Chaves", - "settings-title": "Configurações", - "loading": "Carregando...", - "error": "Erro", - "cancel": "Cancelar", - "confirm": "Confirmar", - "welcome-user": "Bem-vindo, {$username}!", - "notification-count": "{$count ->\n [0] Nenhuma notificação\n [1] Uma notificação\n *[other] {$count} notificações\n }", - "search-placeholder": "Pesquisar...", - "message-input": "Digite sua mensagem...", - "success": "Sucesso", - "warning": "Aviso", - "info": "Informação", - "close": "Fechar", - "open": "Abrir", - "delete": "Excluir", - "edit": "Editar", - "create": "Criar", - "update": "Atualizar", - "refresh": "Atualizar", - "save": "Salvar", - "cancel-button": "Cancelar", - "ok": "OK", - "yes": "Sim", - "no": "Não", - "continue": "Continuar", - "back": "Voltar", - "next": "Próximo", - "finish": "Finalizar", - "about": "Sobre", - "account": "Conta", - "advanced": "Avançado", - "danmu": "Danmu", - "developer": "Desenvolvedor", - "languages": "Idiomas", - "privacy-security": "Privacidade e Segurança", - "streaming": "Transmissão", - "notifications": "Notificações", - "sign-in": "Entrar", - "log-out": "Sair", - "account-greeting": "Olá, @{$handle}.", - "edit-profile-bluesky": "Editar perfil (no Bluesky)", - "change-name-color": "Mudar cor do nome", - "key-management": "Gerenciamento de Chaves", - "key-manager": "Gerenciador de Chaves", - "your-stream-pubkeys": "Suas Chaves Públicas de Transmissão", - "no-keys": "Nenhuma chave configurada", - "pubkey-description": "Chaves públicas são emparelhadas com chaves de transmissão (usadas em software de transmitindo) para assinar e verificar sua transmissão", - "keys-count": "{$count ->\n [one] {$count} chave\n [many] {$count} chaves\n *[other] {$count} chaves\n }", - "webhooks": "Webhooks", - "webhook-integrations": "Integrações de Webhook", - "webhook-integrations-description": "Conecte serviços externos para receber atualizações em tempo real sobre suas transmissões", - "create-webhook": "Criar Webhook", - "edit-webhook": "Editar Webhook", - "delete-webhook": "Excluir Webhook", - "no-webhooks-yet": "Nenhum webhook configurado ainda", - "failed-load-webhooks": "Falha ao carregar webhooks", - "webhook-will-no-longer-receive-events": "Este webhook não receberá mais eventos", - "create-first-webhook-description": "Crie seu primeiro webhook para começar a receber eventos de transmissão", - "example-captain-hook": "Capitão Gancho", - "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", - "activates-on": "Ativa em:", - "events-livestream": "Eventos de Transmissão ao Vivo", - "events-chat": "Eventos de Chat", - "untitled-webhook": "Webhook Sem Título", - "inactive": "Inativo", - "debug-recording": "Gravação de Depuração", - "danmu-enabled": "Ativar Danmu", - "danmu-enabled-description": "Exibir mensagens de chat ao vivo como comentários flutuantes na sua tela", - "danmu-opacity": "Opacidade", - "danmu-speed": "Velocidade", - "danmu-lane-count": "Número de Faixas", - "danmu-max-messages": "Mensagens Máximas", - "app-version-description": "Informações da versão atual", - "confirm-delete": "Tem certeza de que deseja excluir isso?", - "action-cannot-be-undone": "Esta ação não pode ser desfeita", - "name-optional": "Nome (opcional)", - "deleting": "Excluindo...", - "saving": "Salvando...", - "go-to-dashboard": "Ir para o Painel", - "need-setup-live-dashboard": "Você precisa configurar uma transmissão ao vivo no painel primeiro", - "no-languages-found": "Nenhum idioma encontrado", - "backup-save": "Salvar configurações de backup", - "backup-saving": "Salvando configurações de backup...", - "backup-secret-key-set-placeholder": "(Senha já definida)", - "backup-error-invalid-endpoint": "Deve ser um nome de domínio válido", - "backup-error-invalid-bucket": "Deve conter apenas letras minúsculas, números, pontos e hifens", - "backup-error-invalid-segment-duration": "Deve estar entre 1 e 60 segundos", - "backup-error-load-failed": "Falha ao carregar as configurações de armazenamento", - "backup-error-update-failed": "Falha ao atualizar o status do backup", - "backup-error-save-failed": "Falha ao salvar as configurações de armazenamento", - "backup-error-missing-secret": "Não é possível atualizar as configurações do S3 sem a chave secreta. Por favor, insira-a novamente.", - "backup-segment-duration-placeholder": "6", - "backup": "Backup", - "backup-enabled": "S3 Backup", - "backup-enabled-description": "Fazer backup automaticamente das gravações em armazenamento compatível com S3", - "backup-connection-url": "URL de conexão", - "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", - "backup-endpoint": "Endpoint", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "Bucket", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "Chave de acesso", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "Chave secreta", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "show-password-in-url": "Mostrar senha na URL", - "show-password-in-url-description": "Exibir a chave secreta na URL de conexão (se inserida)", - "recommendations-to-others": "Recomendações para outros", - "recommendations-description": "Compartilhe até 8 streamers que você recomenda aos seus espectadores", - "no-recommendations-yet": "Nenhuma recomendação configurada ainda", - "multistream": "Multitransmissão", - "multistream-targets": "Destinos de multitransmissão", - "multistream-description": "Envie automaticamente suas transmissões ao vivo do Streamplace para outros serviços como Twitch ou YouTube.", - "create-multistream-target": "Criar destino de multitransmissão", - "untitled-multistream-target": "Destino sem título", - "failed-load-multistream-targets": "Falha ao carregar os destinos de multitransmissão. Por favor, tente novamente.", - "failed-toggle-multistream-target": "Falha ao alternar o destino de multitransmissão. Por favor, tente novamente.", - "no-multistream-targets-yet": "Nenhum destino ainda!", - "multistream-targets-count": "{$count ->\n [one] {$count} destino\n [many] {$count} destinos\n *[other] {$count} destinos\n }", - "multistream-delete-target-confirmation": "Tem certeza de que deseja excluir \"{$target}\"?", - "this-action-cannot-be-undone": "Esta ação não pode ser desfeita.", - "rtmp-target-name": "Destino RTMP", - "rtmp-target-url": "URL RTMP", - "rtmp-target-name-placeholder": "Meu destino de multitransmissão", - "multistream-create-target": "Criar destino", - "multistream-edit-target": "Editar destino", - "created": "criado", - "status": "status", - "branding": "Identidade visual", - "branding-admin": "Administração de identidade visual", - "branding-admin-description": "Personalize sua instância do Streamplace. As configurações podem levar algumas horas para se propagar.", - "branding-login-required": "Faça login para gerenciar a identidade visual", - "branding-configuration": "Configuração", - "branding-text-settings": "Configurações de texto", - "branding-colors": "Cores", - "branding-legal-links": "Links legais", - "branding-images": "Imagens", - "branding-broadcaster-did": "DID do transmissor", - "branding-broadcaster-did-description": "Deixe vazio para usar o padrão do servidor", - "branding-site-title": "Título do site", - "branding-site-title-placeholder": "Digite o novo título do site", - "branding-site-description": "Descrição do site", - "branding-site-description-placeholder": "Digite a descrição do site", - "branding-default-streamer": "Streamer padrão", - "branding-default-streamer-none": "Nenhum", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "Limpar streamer padrão", - "branding-primary-color": "Cor principal", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Cor de destaque", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "Logo principal", - "branding-main-logo-description": "SVG, PNG ou JPEG (máx. 500KB)", - "branding-favicon": "Favicon", - "branding-favicon-description": "SVG, PNG ou ICO (máx. 100KB)", - "branding-sidebar-bg": "Imagem de fundo da barra lateral", - "branding-sidebar-bg-description": "SVG, PNG ou JPEG (máx. 500kb) - aparece alinhada na parte inferior da barra lateral, largura total. Faça upload de uma imagem com opacidade para melhores resultados, pois atualmente não há uma opção de opacidade separada.", - "branding-current": "Atual: {$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "Enviar logo", - "branding-delete-logo": "Excluir logo", - "branding-upload-favicon": "Enviar favicon", - "branding-delete-favicon": "Excluir favicon", - "branding-upload-background": "Enviar plano de fundo", - "branding-delete-background": "Excluir plano de fundo", - "branding-web-only": "O upload de imagens só está disponível na web.", - "refresh-branding": "Atualizar recursos de identidade visual", - "branding-add-legal-link": "Adicionar link legal", - "branding-edit-legal-link": "Editar link legal", - "branding-legal-link-text-placeholder": "Texto do link (ex., Política de Privacidade)", - "branding-legal-link-url-placeholder": "URL (ex., https://exemplo.com/privacidade)", - "add": "Adicionar", - "active": "Ativo", - "optional": "opcional", - "branding-not-authenticated": "Faça login primeiro", - "branding-empty-value": "Por favor, insira um valor", - "branding-update-success": "{$key} atualizado com sucesso", - "branding-upload-success": "{$key} enviado com sucesso", - "branding-delete-success": "{$key} excluído com sucesso", - "branding-upload-failed": "Falha ao enviar", - "branding-delete-failed": "Falha ao excluir", - "branding-not-available": "O upload de arquivos só está disponível na web", - "node-legal-documents": "Documentos específicos do transmissor", - "badges": "Emblemas", - "badges-cosmetic-section": "Emblemas cosméticos", - "badges-empty-state": "Você ainda não recebeu nenhum emblema.", - "badges-failed-load": "Falha ao carregar emblemas", - "badges-failed-update": "Falha ao atualizar a seleção de emblemas", - "badges-issued-by": "Emitido por {$issuer}", - "badges-streamer-section": "Emblemas do streamer", - "issue-badges": "Emitir emblemas", - "issue-badges-back-to-definitions": "Definições de emblemas", - "issue-badges-badge-name": "Nome do emblema", - "issue-badges-badge-name-placeholder": "ex. VIP, Apoiador", - "issue-badges-badge-type": "Tipo de emblema", - "issue-badges-choose-image": "Escolher imagem", - "issue-badges-create-definition": "Criar definição de emblema", - "issue-badges-create-definition-description": "Defina um novo tipo de emblema que pode ser emitido para espectadores", - "issue-badges-create-definition-subtitle": "Defina um novo tipo de emblema", - "issue-badges-definition-created": "Definição de emblema criada", - "issue-badges-description-optional": "Descrição (opcional)", - "issue-badges-description-placeholder": "Descrição opcional para este emblema", - "issue-badges-failed-create": "Falha ao criar a definição de emblema", - "issue-badges-failed-issue": "Falha ao emitir o emblema", - "issue-badges-failed-load": "Falha ao carregar as definições de emblemas", - "issue-badges-image-optional": "Imagem (opcional)", - "issue-badges-image-too-large": "A imagem deve ter menos de 256KB", - "issue-badges-image-web-only": "O upload de imagens só está disponível na web", - "issue-badges-issue-badge": "Emitir emblema", - "issue-badges-issue-badge-description": "Emita o emblema \"{$name}\" para um espectador pelo seu DID", - "issue-badges-issued": "Emblema emitido", - "issue-badges-issued-to": "Emitido para {$did}", - "issue-badges-manage-description": "Crie definições de emblemas e emita-os para espectadores", - "issue-badges-recipient-did": "DID do destinatário", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "Toque para emitir", - "issue-badges-your-definitions": "Suas definições de emblemas" -} diff --git a/js/web/public/locales/ro-RO/common.json b/js/web/public/locales/ro-RO/common.json deleted file mode 100644 index 8b7137fdb..000000000 --- a/js/web/public/locales/ro-RO/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "Se încarcă...", - "error": "Eroare", - "cancel": "Anulare", - "confirm": "Confirmare", - "close": "Închidere", - "open": "Deschidere", - "ok": "OK", - "yes": "Da", - "no": "Nu", - "continue": "Continuare", - "back": "Înapoi", - "next": "Următorul", - "finish": "Finalizare", - "save": "Salvare", - "delete": "Ștergere", - "edit": "Editare", - "create": "Creare", - "update": "Actualizare", - "refresh": "Reîmprospătare", - "success": "Succes", - "warning": "Avertisment", - "info": "Informație", - "search-placeholder": "Căutare...", - "message-input": "Introduceți-vă mesajul...", - "please-log-in-to-access-this-page": "Vă rugăm să vă conectați pentru a accesa această pagină", - "go-to-settings": "Mergeți la Setări", - "go-back": "Mergeți înapoi", - "welcome-user": "Bun venit, {$username}!", - "notification-count": "{$count ->\n [0] Nici o notificare\n [1] O notificare\n *[other] {$count} (de) notificări\n }", - "user-offline": "utilizatorul este offline", - "user-offline-message": "{$source ->\n [streamer] Se pare că <1>@{$handle} este offline, dar recomandă să vizionați:\n *[default] Se pare că <1>@{$handle} este offline, dar vă recomandăm să vizionați:\n }", - "user-offline-no-recommendations": "Se pare că <1>@{$handle} este offline acum.\nReveniți mai târziu.", - "streaming-title": "transmisie în direct {$title}", - "viewer-count": "{$count ->\n [0] 0 vizionări\n [1] 1 vizionare\n *[other] {$count} (de) vizionări\n }", - "pds-selector-title": "Nou pe Atmosphere?", - "pds-selector-description": "Va trebui să selectați un PDS (Personal Data Server) pentru a accesa aplicațiile de pe Atmosphere, cum ar fi Bluesky, Tangled și Spark.", - "pds-selector-custom-label": "Alt PDS", - "pds-selector-custom-description": "Introduceți adresa URL a propriei gazde PDS", - "pds-selector-custom-url-label": "URL PDS personalizat", - "pds-selector-custom-url-placeholder": "https://pds.exemplu.ro", - "pds-selector-learn-more": "Aflați mai multe despre găzduirea proprie", - "pds-selector-info": "Fiecare gazdă are propriile politici și standarde de fiabilitate. Datele dvs. ATProto se află pe gazda pe care o alegeți și le puteți migra ulterior. Notă: Streamplace are propriile reguli de moderare - accesul dvs. poate fi interzis pe Streamplace indiferent de gazda pe care o alegeți.", - "pds-selector-read-policies": "Citiți Termenii și condițiile și Politica de confidențialitate ale {$label} înainte de a continua.", - "pds-selector-handle-policy-checkbox": "Am citit și sunt de acord cu politica de gestionare", - "login-show-live-on-bluesky": "Arată când sunt live pe Bluesky", - "login-show-live-on-bluesky-description": "Adaugă inelul roșu LIVE avatarului dvs. de Bluesky în timp ce transmiteți și permite Streamplace să publice anunțuri în numele dvs. Debifați pentru a vă conecta fără a acorda niciun acces la contul dvs. de Bluesky." -} diff --git a/js/web/public/locales/ro-RO/settings.json b/js/web/public/locales/ro-RO/settings.json deleted file mode 100644 index 38ee3e585..000000000 --- a/js/web/public/locales/ro-RO/settings.json +++ /dev/null @@ -1,211 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "Descărcați noua actualizare", - "check-for-updates": "Căutați actualizări", - "bundled-runtype": "Bundled", - "ota-runtype": "Over-the-Air (OTA)", - "recovery-runtype": "Mod de recuperare", - "modal-latest-version": "Aveți cea mai recentă versiune.", - "modal-no-update-available": "Aveți cea mai recentă versiune de Streamplace, ura!", - "modal-update-available-title": "Actualizare disponibilă", - "modal-update-available-description": "O nouă versiune de Streamplace este gata de descărcare", - "modal-update-failed": "Căutarea actualizării a eșuat. Este posibil să fie nevoie să actualizați aplicația prin {$store}.", - "modal-update-failed-title": "Actualizare eșuată", - "modal-update-failed-description": "Căutarea actualizării a eșuat. Este posibil să fie nevoie să actualizați aplicația prin {$store}.", - "button-reload-app-on-update": "Aplicați actualizarea (va reîncărca aplicația)", - "use-custom-node": "Utilizați nodul personalizat", - "default-url": "Default: {$url}", - "enter-custom-node-url": "Introduceți adresa URL a nodului personalizat", - "save-button": "Salvare", - "try-new-web": "Încercați noul web (beta)", - "try-new-web-description": "Comutați la noua interfață web. Puteți reveni oricând din aceeași setare.", - "try-old-app": "Folosiți interfața originală", - "try-old-app-description": "Reveniți la interfața originală Streamplace.", - "language-selection": "Limbă", - "language-selection-description": "Alegeți-vă limba preferată", - "input-search-languages": "Căutați limbi...", - "help-translate": "Ajutați-ne să traducem Streamplace", - "help-translate-description": "Căutăm voluntari care să ne ajute la traducerea Streamplace în mai multe limbi. Dacă sunteți interesați, vă rugăm să ne contactați pe Discord sau GitHub!", - "currently-translating": "Traducerile sunt pe drum", - "currently-translating-description": "Este posibil ca unele părți ale aplicației să pară incomplete. Vă mulțumim pentru răbdare!", - "debug-recording-title": "Permiteți-i lui {$host} să înregistreze transmisiunea live pentru depanare și îmbunătățirea serviciului", - "debug-recording-description": "Opțional", - "manage-keys": "Gestionare chei", - "settings-title": "Setări", - "about": "Despre", - "account": "Cont", - "advanced": "Complex", - "danmu": "Danmu", - "developer": "Dezvoltator", - "languages": "Limbi", - "privacy-security": "Confidențialitate și securitate", - "streaming": "Streaming", - "notifications": "Notificări", - "cancel": "Anulare", - "create": "Creare", - "delete": "Ștergere", - "refresh": "Reîmprospătare", - "sign-in": "Conectare", - "update": "Actualizare", - "log-out": "Deconectare", - "optional": "opțional", - "account-greeting": "Hei, @{$handle}.", - "edit-profile-bluesky": "Editare profil (pe Bluesky)", - "change-name-color": "Schimbați culoarea numelui", - "key-management": "Gestionare chei", - "key-manager": "Manager cheie", - "your-stream-pubkeys": "Cheile publice ale transmisiunii dvs.", - "no-keys": "Nicio cheie configurată", - "pubkey-description": "Cheile publice sunt asociate cu cheile de transmisiune (utilizate în software-ul de streaming) pentru a semna și verifica transmisiunea.", - "keys-count": "{$count ->\n [one] {$count} cheie\n *[other] {$count} chei\n }", - "recommendations": "Recomandări", - "manage-recommendations": "Gestionați recomandările", - "recommendations-to-others": "Recomandări pentru Alții", - "recommendations-description": "Distribuie până la 8 streameri pe care îi recomandați spectatorilor dvs.", - "no-recommendations-yet": "Nu au fost configurate recomandări încă", - "add-recommendation": "Adăugare recomandare", - "streamer-did": "DID Streamer", - "recommendations-count": "{$count ->\n [one] {$count} recomandare\n *[other] {$count} (de) recomandări\n }", - "webhooks": "Webhook-uri", - "webhook-integrations": "Integrari Webhook", - "webhook-integrations-description": "Conectați servicii externe pentru a primi actualizări în timp real despre transmisiunile dvs.", - "create-webhook": "Creare Webhook", - "edit-webhook": "Editare Webhook", - "delete-webhook": "Ștergere Webhook", - "no-webhooks-yet": "Nu sunt încă configurate webhook-uri", - "failed-load-webhooks": "Nu s-au putut încărca webhook-urile", - "webhook-will-no-longer-receive-events": "Acest webhook nu va mai primi evenimente", - "create-first-webhook-description": "Creați-vă primul webhook pentru a începe să primiți evenimente de transmisiune", - "example-captain-hook": "Captain Hook", - "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} (de) webhook-uri\n }", - "activates-on": "Se activează la:", - "events": "Evenimente", - "events-livestream": "Evenimente în transmisiuni", - "events-chat": "Evenimente de conversașie", - "untitled-webhook": "Webhook fără titlu", - "inactive": "Inactiv", - "active": "Activ", - "multistream": "Multi-transmisiune", - "multistream-targets": "Ținte multi-transmisiune", - "multistream-description": "Distribuie automat transmisiunile tale live Streamplace către alte servicii de streaming precum Twitch sau YouTube.", - "create-multistream-target": "Creare țintă multi-transmisiune", - "untitled-multistream-target": "Țintă fără titlu", - "failed-load-multistream-targets": "Nu s-au putut încărca țintele multi-transmisiune. Vă rugăm să încercați din nou.", - "failed-toggle-multistream-target": "Nu s-a putut comuta ținta multi-transmisiune. Vă rugăm să încercați din nou.", - "failed-delete-multistream-target": "Ștergerea țintei multi-transmisiune nu a reușit. Încercați din nou.", - "no-multistream-targets-yet": "Nu există încă ținte!", - "multistream-targets-count": "{$count ->\n [one] {$count} țintă\n *[other] {$count} (de) ținte\n }", - "multistream-delete-target-confirmation": "Sunteți sigur că vreți să ștergeți „{$target}”?", - "this-action-cannot-be-undone": "Această acțiune nu poate fi anulată.", - "rtmp-target-name": "Țintă RTMP", - "rtmp-target-url": "URL RTMP", - "rtmp-target-name-placeholder": "Ținta mea transmisiune", - "multistream-create-target": "Creare țintă", - "multistream-edit-target": "Editare țintă", - "created": "creată", - "status": "stare", - "debug-recording": "Depanare înregistrare", - "danmu-enabled": "Activare Danmu", - "danmu-enabled-description": "Afișați mesajele din chatul live ca comentarii plutitoare pe ecran", - "danmu-opacity": "Opacitate", - "danmu-speed": "Viteză", - "danmu-lane-count": "Număr de benzi", - "danmu-max-messages": "Mesaje maxime", - "app-version-description": "Nu sunt disponibile actualizări în acest moment", - "confirm-delete": "Sigur vreți să ștergeți asta?", - "action-cannot-be-undone": "Această acțiune nu poate fi anulată", - "name-optional": "Nume (opțional)", - "deleting": "Se șterge...", - "saving": "Se salvează...", - "go-to-dashboard": "Mergeți la tabloul de bord", - "need-setup-live-dashboard": "Trebuie să configurați mai întâi transmisiunile? Vizitați tabloul de bord al transmisiunilor", - "no-languages-found": "Nu s-au găsit limbi", - "branding": "Branding", - "branding-admin": "Administrarea brandingului", - "branding-admin-description": "Personalizați-vă instanța Streamplace. Rețineți că propagarea setărilor poate dura câteva ore.", - "branding-login-required": "Vă rugăm să vă conectați pentru a gestiona brandingul", - "branding-configuration": "Configurare", - "branding-text-settings": "Setări text", - "branding-colors": "Culori", - "branding-legal-links": "Link-uri legale", - "branding-images": "Imagini", - "branding-broadcaster-did": "DID radiodifuzor", - "branding-broadcaster-did-description": "Lăsați gol pentru a utiliza valorile implicite ale serverului", - "branding-site-title": "Titlul site-ului", - "branding-site-title-placeholder": "Introduceți un nou titlu de site", - "branding-site-description": "Descrierea site-ului", - "branding-site-description-placeholder": "Introduceți descrierea site-ului", - "branding-default-streamer": "Streamer implicit", - "branding-default-streamer-none": "Nimic", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "Ștergeți Streamerul implicit", - "branding-primary-color": "Culoare primară", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "Culoare de accent", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "Logo principal", - "branding-main-logo-description": "SVG, PNG, or JPEG (max 500KB)", - "branding-favicon": "Favicon", - "branding-favicon-description": "SVG, PNG, sau ICO (maxim 100KB)", - "branding-sidebar-bg": "Imagine de fundal din bara laterală", - "branding-sidebar-bg-description": "SVG, PNG sau JPEG (maxim 500kb) - apare aliniat la partea de jos a barei laterale, pe lățime completă. Încărcați o imagine cu opacitate pentru cele mai bune rezultate, deoarece în prezent nu există o opțiune separată de opacitate.", - "branding-current": "Current: {$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "Încărcare logo", - "branding-delete-logo": "Ștergere logo", - "branding-upload-favicon": "Încărcare Favicon", - "branding-delete-favicon": "Ștergere Favicon", - "branding-upload-background": "Încărcare imagine de fundal", - "branding-delete-background": "Ștergere imagine de fundal", - "branding-web-only": "Încărcarea imaginilor este disponibilă doar pe web.", - "refresh-branding": "Actualizați elementele de branding", - "branding-add-legal-link": "Adăugați link legal", - "branding-edit-legal-link": "Editați linkul legal", - "branding-legal-link-text-placeholder": "Textul linkului (de exemplu, Politica de confidențialitate)", - "branding-legal-link-url-placeholder": "Adresa URL (de ex., https://example.com/privacy)", - "add": "Adăugare", - "edit": "Editare", - "branding-not-authenticated": "Vă rugăm să vă conectați mai întâi", - "branding-empty-value": "Vă rugăm să introduceți o valoare", - "branding-update-success": "{$key} a fost actualizat cu succes", - "branding-upload-success": "{$key} a fost încărcat cu succes", - "branding-delete-success": "{$key} a fost șters cu succes", - "branding-upload-failed": "Încărcare eșuată", - "branding-delete-failed": "Ștergere eșuată", - "branding-not-available": "Încărcarea de fișiere este disponibilă doar pe web.", - "node-legal-documents": "Documente specifice radiodifuzorului", - "badges": "Insigne", - "badges-cosmetic-section": "Insigne cosmetice", - "badges-empty-state": "Nu ți-a fost acordată nicio insignă încă.", - "badges-failed-load": "Nu s-au putut încărca insignele", - "badges-failed-update": "Nu s-a putut actualiza selecția de insigne", - "badges-issued-by": "Acordată de {$issuer}", - "badges-streamer-section": "Insigne de la streameri", - "issue-badges": "Emitere insigne", - "issue-badges-back-to-definitions": "Definiții insigne", - "issue-badges-badge-name": "Numele insignei", - "issue-badges-badge-name-placeholder": "ex. VIP, Susținător", - "issue-badges-badge-type": "Tipul insignei", - "issue-badges-choose-image": "Alegeți imaginea", - "issue-badges-create-definition": "Creare definiție insignă", - "issue-badges-create-definition-description": "Definiți un nou tip de insignă care poate fi acordată spectatorilor", - "issue-badges-create-definition-subtitle": "Definiți un nou tip de insignă", - "issue-badges-definition-created": "Definiție insignă creată", - "issue-badges-description-optional": "Descriere (opțional)", - "issue-badges-description-placeholder": "Descriere opțională pentru această insignă", - "issue-badges-failed-create": "Nu s-a putut crea definiția insignei", - "issue-badges-failed-issue": "Nu s-a putut emite insigna", - "issue-badges-failed-load": "Nu s-au putut încărca definițiile insignelor", - "issue-badges-image-optional": "Imagine (opțional)", - "issue-badges-image-too-large": "Imaginea trebuie să fie mai mică de 256KB", - "issue-badges-image-web-only": "Încărcarea imaginilor este disponibilă doar pe web", - "issue-badges-issue-badge": "Emite insignă", - "issue-badges-issue-badge-description": "Emite insigna „{$name}\" unui spectator prin DID-ul său", - "issue-badges-issued": "Insignă emisă", - "issue-badges-issued-to": "Emisă pentru {$did}", - "issue-badges-manage-description": "Creați definiții de insigne și emiteți-le spectatorilor", - "issue-badges-recipient-did": "DID destinatar", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "Atingeți pentru a emite", - "issue-badges-your-definitions": "Definițiile dvs. de insigne" -} diff --git a/js/web/public/locales/zh-Hans/common.json b/js/web/public/locales/zh-Hans/common.json deleted file mode 100644 index 082dd6d61..000000000 --- a/js/web/public/locales/zh-Hans/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "正在加载...", - "error": "错误", - "cancel": "取消", - "confirm": "确认", - "close": "关闭", - "open": "开启", - "ok": "确定", - "yes": "是", - "no": "否", - "continue": "继续", - "back": "返回", - "next": "下一步", - "finish": "完成", - "save": "保存", - "delete": "删除", - "edit": "编辑", - "create": "创建", - "update": "更新", - "refresh": "刷新", - "success": "成功", - "warning": "警告", - "info": "信息", - "search-placeholder": "搜索...", - "message-input": "输入您的消息...", - "please-log-in-to-access-this-page": "请登录以访问此页面", - "go-to-settings": "前往设置", - "go-back": "返回", - "welcome-user": "欢迎,{$username}!", - "notification-count": "{$count ->\n [0] 无通知\n *[other] {$count} 则通知\n }", - "user-offline": "用户离线", - "user-offline-message": "{$source ->\n [streamer] 看起来 <1>@{$handle} 离线了,但他们推荐观看:\n *[default] 看起来 <1>@{$handle} 离线了,但我们推荐观看:\n }", - "user-offline-no-recommendations": "看起来 <1>@{$handle} 离线了。\n请稍后再来看看。", - "streaming-title": "正在直播 {$title}", - "viewer-count": "{$count} 位观众", - "pds-selector-title": "刚接触 Atmosphere 吗?", - "pds-selector-description": "您需要选择一个 PDS (个人数据服务器) 来访问 Atmosphere 上的应用程序,例如 Bluesky、Tangled 和 Spark。", - "pds-selector-custom-label": "另一个 PDS", - "pds-selector-custom-description": "请输入您自己的 PDS 主机网址", - "pds-selector-custom-url-label": "自定义 PDS 网址", - "pds-selector-custom-url-placeholder": "https://pds.example.com", - "pds-selector-learn-more": "了解有关自组主机的更多信息", - "pds-selector-info": "每个主机都有自己的政策和可靠性标准。您的 ATProto 数据存储在您选择的主机上,您可以以后进行迁移。注意:Streamplace 有自己的审核规则——无论您选择哪个主机,都可能会被 Streamplace 禁止使用。", - "pds-selector-read-policies": "在继续之前,请阅读 {$label} 的服务条款和隐私政策。", - "pds-selector-handle-policy-checkbox": "我已阅读并同意处理政策", - "login-show-live-on-bluesky": "在 Bluesky 上显示我的直播状态", - "login-show-live-on-bluesky-description": "直播时为您的 Bluesky 头像加上红色 LIVE 圆环,并允许 Streamplace 代您发布直播公告。取消勾选即可在不授予任何 Bluesky 账号访问权限的情况下登录。" -} diff --git a/js/web/public/locales/zh-Hans/settings.json b/js/web/public/locales/zh-Hans/settings.json deleted file mode 100644 index 9af46c5ca..000000000 --- a/js/web/public/locales/zh-Hans/settings.json +++ /dev/null @@ -1,246 +0,0 @@ -{ - "app-version": "Streamplace v{$version}", - "download-new-update": "下载新更新", - "check-for-updates": "检查更新", - "bundled-runtype": "捆绑版", - "ota-runtype": "空中下载 (OTA)", - "recovery-runtype": "复原模式", - "modal-latest-version": "您正在使用最新版本。", - "modal-no-update-available": "您已经在使用最新版本的 Streamplace,太棒了!", - "modal-update-available-title": "有可用更新", - "modal-update-available-description": "新版本的 Streamplace 已准备好下载", - "modal-update-failed": "更新检查失败。您可能需要透过 {$store} 更新应用程序。", - "modal-update-failed-title": "更新失败", - "modal-update-failed-description": "更新检查失败。您可能需要透过 {$store} 更新应用程序。", - "button-reload-app-on-update": "套用更新 (将重新加载应用程序)", - "use-custom-node": "使用自定义节点", - "default-url": "默认:{$url}", - "enter-custom-node-url": "输入自定义节点网址", - "save-button": "保存", - "try-new-web": "试用新版网页 (beta)", - "try-new-web-description": "切换到新的网页界面。你可以随时通过此设置切换回来。", - "try-old-app": "使用原版界面", - "try-old-app-description": "返回 Streamplace 原版界面。", - "language-selection": "语言", - "language-selection-description": "选择您偏好的语言", - "input-search-languages": "搜索语言...", - "help-translate": "帮助我们翻译 Streamplace", - "help-translate-description": "我们正在寻找志愿者协助将 Streamplace 翻译成更多语言。如果您有兴趣,请在 Discord 或 GitHub 上与我们联系!", - "currently-translating": "翻译正在进行中", - "currently-translating-description": "应用程序的某些部分可能看起来不完整。感谢您的耐心等待!", - "debug-recording-title": "允许 {$host} 录制您的直播串流以进行除错和服务改善", - "debug-recording-description": "可选项", - "manage-keys": "管理密钥", - "settings-title": "设置", - "error": "错误", - "about": "关于", - "account": "帐户", - "advanced": "高级", - "danmu": "弹幕", - "developer": "开发者", - "languages": "语言", - "privacy-security": "隐私与安全", - "streaming": "串流", - "notifications": "通知", - "cancel": "取消", - "create": "创建", - "delete": "删除", - "refresh": "刷新", - "sign-in": "登录", - "update": "更新", - "log-out": "登出", - "optional": "选填", - "account-greeting": "嗨,@{$handle}。", - "edit-profile-bluesky": "在 Bluesky 编辑个人数据", - "change-name-color": "更改名称颜色", - "key-management": "密钥管理", - "key-manager": "密钥管理器", - "your-stream-pubkeys": "您的串流公开密钥", - "no-keys": "尚未设置密钥", - "pubkey-description": "公开密钥与串流密钥 (用于串流软件) 配对以签署和验证您的串流", - "keys-count": "{$count} 个密钥", - "backup": "备份", - "backup-enabled": "S3 备份", - "backup-enabled-description": "自动将录影备份到兼容 S3 的存储", - "backup-connection-url": "连接网址", - "backup-connection-url-placeholder": "必须是格式为 s3+https://ACCESS_KEY:SECRET_KEY@region.endpoi.nt/bucket 的有效 S3 网址", - "backup-endpoint": "终端", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "存储桶", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "访问密钥", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "秘密密钥", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "backup-region": "地区", - "backup-region-placeholder": "us-east-1", - "backup-test-connection": "测试连接", - "backup-testing-connection": "正在测试连接...", - "backup-connection-successful": "连接成功", - "backup-connection-failed": "连接失败", - "show-password-in-url": "在网址中显示密码", - "show-password-in-url-description": "在连接网址中显示秘密密钥 (若有)", - "requested-seconds-per-segment": "每段秒数", - "requested-seconds-per-segment-description": "设置每段秒数,以请求服务器使用", - "recommendations": "推荐主播", - "manage-recommendations": "管理推荐主播", - "recommendations-to-others": "向他人推荐主播", - "recommendations-description": "向您的观众推荐最多 8 位主播", - "no-recommendations-yet": "尚未配置推荐", - "add-recommendation": "添加推荐", - "streamer-did": "主播 DID", - "recommendations-count": "{$count} 位推荐主播", - "webhooks": "Webhooks", - "webhook-integrations": "Webhook 集成", - "webhook-integrations-description": "连接外部服务以即时接收有关您串流的更新", - "create-webhook": "创建 Webhook", - "edit-webhook": "编辑 Webhook", - "delete-webhook": "删除 Webhook", - "no-webhooks-yet": "尚未设置 Webhook", - "failed-load-webhooks": "加载 Webhook 失败", - "webhook-will-no-longer-receive-events": "此 Webhook 将不再接收事件", - "create-first-webhook-description": "创建您的第一个 Webhook 以开始接收串流事件", - "example-captain-hook": "Hook 船长", - "webhooks-count": "{$count} 个 Webhook", - "activates-on": "触发于:", - "events": "事件", - "events-livestream": "直播串流事件", - "events-chat": "聊天事件", - "untitled-webhook": "未命名的 Webhook", - "inactive": "停用", - "active": "活动", - "multistream": "多重串流", - "multistream-targets": "多重串流目标", - "multistream-description": "自动将您的 Streamplace 直播推送到 Twitch 或 YouTube 等其他直播平台。", - "create-multistream-target": "创建多重串流目标", - "untitled-multistream-target": "未命名目标", - "failed-load-multistream-targets": "加载多重串流目标失败。请重试。", - "failed-toggle-multistream-target": "切换多重串流目标失败。请重试。", - "failed-delete-multistream-target": "删除多重串流目标失败。请重试。", - "no-multistream-targets-yet": "还没有目标!", - "multistream-targets-count": "{$count} 个目标", - "multistream-delete-target-confirmation": "您确定要删除“{$target}”吗?", - "this-action-cannot-be-undone": "此操作无法撤销。", - "rtmp-target-name": "RTMP 目标", - "rtmp-target-url": "RTMP 网址", - "rtmp-target-name-placeholder": "我的多重串流目标", - "multistream-create-target": "创建目标", - "multistream-edit-target": "编辑目标", - "created": "创建于", - "status": "状态", - "debug-recording": "调试录制", - "danmu-enabled": "启用弹幕", - "danmu-enabled-description": "将即时聊天消息以浮动评论的形式显示在您的屏幕上", - "danmu-opacity": "不透明度", - "danmu-speed": "速度", - "danmu-lane-count": "轨道数量", - "danmu-max-messages": "最大消息数", - "app-version-description": "当前没有可用的更新", - "confirm-delete": "您确定要删除吗?", - "action-cannot-be-undone": "此操作无法撤销", - "name-optional": "名称 (选填)", - "deleting": "正在删除...", - "saving": "正在保存...", - "go-to-dashboard": "前往仪表板", - "need-setup-live-dashboard": "需要先设置串流吗?请访问直播仪表板", - "no-languages-found": "找不到语言", - "branding": "品牌", - "branding-admin": "品牌管理", - "branding-admin-description": "自定义您的 Streamplace 实例。请注意,设置可能需要几小时才能生效。", - "branding-login-required": "请登录以管理品牌", - "branding-configuration": "配置", - "branding-text-settings": "文字设置", - "branding-colors": "颜色", - "branding-legal-links": "法律链接", - "branding-images": "图像", - "branding-broadcaster-did": "主播 DID", - "branding-broadcaster-did-description": "留空以使用服务器默认值", - "branding-site-title": "网站标题", - "branding-site-title-placeholder": "输入新网站标题", - "branding-site-description": "网站描述", - "branding-site-description-placeholder": "输入网站描述", - "branding-default-streamer": "默认主播", - "branding-default-streamer-none": "无", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "清除默认主播", - "branding-primary-color": "首要颜色", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "强调色", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "主标志", - "branding-main-logo-description": "SVG、PNG 或 JPEG (最大 500KB)", - "branding-favicon": "网站图标", - "branding-favicon-description": "SVG、PNG 或 ICO (最大 100KB)", - "branding-sidebar-bg": "侧边栏背景图片", - "branding-sidebar-bg-description": "SVG、PNG 或 JPEG (最大 500KB)——显示在侧边栏底部,全宽显示。为了获得最佳效果,请上传一张带有透明度的图片,因为目前没有单独的透明度选项。", - "branding-current": "当前:{$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "上传标志", - "branding-delete-logo": "删除标志", - "branding-upload-favicon": "上传网站图标", - "branding-delete-favicon": "删除网站图标", - "branding-upload-background": "上传背景", - "branding-delete-background": "删除背景", - "branding-web-only": "图像上传仅在网页端可用。", - "refresh-branding": "更新品牌资源", - "branding-add-legal-link": "添加法律链接", - "branding-edit-legal-link": "编辑法律链接", - "branding-legal-link-text-placeholder": "链接文本 (例如:隐私政策)", - "branding-legal-link-url-placeholder": "网址 (例如:https://example.com/privacy)", - "add": "添加", - "edit": "编辑", - "branding-not-authenticated": "请先登录", - "branding-empty-value": "请输入一个数值", - "branding-update-success": "{$key} 更新成功", - "branding-upload-success": "{$key} 上传成功", - "branding-delete-success": "{$key} 删除成功", - "branding-upload-failed": "上传失败", - "branding-delete-failed": "删除失败", - "branding-not-available": "文件上传仅在网页版上可用", - "node-legal-documents": "主播专属文档", - "backup-save": "保存备份设置", - "backup-saving": "正在保存备份设置...", - "backup-secret-key-set-placeholder": "(密码已设置)", - "backup-error-invalid-endpoint": "必须是有效的域名", - "backup-error-invalid-bucket": "必须只包含小写字母、数字、点和连字符", - "backup-error-invalid-segment-duration": "必须在 1 到 60 秒之间", - "backup-error-load-failed": "加载存储设置失败", - "backup-error-update-failed": "更新备份状态失败", - "backup-error-save-failed": "保存存储设置失败", - "backup-error-missing-secret": "没有秘密密钥时无法更新 S3 设置。请重新输入。", - "backup-segment-duration-placeholder": "6", - "badges": "徽章", - "badges-cosmetic-section": "装饰性徽章", - "badges-empty-state": "您还没有获得任何徽章。", - "badges-failed-load": "加载徽章失败", - "badges-failed-update": "更新徽章选择失败", - "badges-issued-by": "由 {$issuer} 颁发", - "badges-streamer-section": "主播徽章", - "issue-badges": "颁发徽章", - "issue-badges-back-to-definitions": "徽章定义", - "issue-badges-badge-name": "徽章名称", - "issue-badges-badge-name-placeholder": "例如 VIP、支持者", - "issue-badges-badge-type": "徽章类型", - "issue-badges-choose-image": "选择图片", - "issue-badges-create-definition": "创建徽章定义", - "issue-badges-create-definition-description": "定义可颁发给观众的新徽章类型", - "issue-badges-create-definition-subtitle": "定义新徽章类型", - "issue-badges-definition-created": "徽章定义已创建", - "issue-badges-description-optional": "描述(可选)", - "issue-badges-description-placeholder": "此徽章的可选描述", - "issue-badges-failed-create": "创建徽章定义失败", - "issue-badges-failed-issue": "颁发徽章失败", - "issue-badges-failed-load": "加载徽章定义失败", - "issue-badges-image-optional": "图片(可选)", - "issue-badges-image-too-large": "图片必须小于 256KB", - "issue-badges-image-web-only": "图片上传仅在网页版可用", - "issue-badges-issue-badge": "颁发徽章", - "issue-badges-issue-badge-description": "通过 DID 将\"{$name}\"徽章颁发给观众", - "issue-badges-issued": "徽章已颁发", - "issue-badges-issued-to": "已颁发给 {$did}", - "issue-badges-manage-description": "创建徽章定义并颁发给观众", - "issue-badges-recipient-did": "收件人 DID", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "点击颁发", - "issue-badges-your-definitions": "您的徽章定义" -} diff --git a/js/web/public/locales/zh-Hant/common.json b/js/web/public/locales/zh-Hant/common.json deleted file mode 100644 index e06e5aa8b..000000000 --- a/js/web/public/locales/zh-Hant/common.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "loading": "載入中...", - "error": "錯誤", - "cancel": "取消", - "confirm": "確認", - "close": "關閉", - "open": "開啟", - "ok": "確定", - "yes": "是", - "no": "否", - "continue": "繼續", - "back": "返回", - "next": "下一步", - "finish": "完成", - "save": "儲存", - "delete": "刪除", - "edit": "編輯", - "create": "建立", - "update": "更新", - "refresh": "重新整理", - "success": "成功", - "warning": "警告", - "info": "資訊", - "search-placeholder": "搜尋...", - "message-input": "輸入您的訊息...", - "please-log-in-to-access-this-page": "請登入以存取此頁面", - "go-to-settings": "前往設定", - "go-back": "返回", - "welcome-user": "歡迎,{$username}!", - "notification-count": "{$count ->\n [0] 無通知\n [1] 一則通知\n *[other] {$count} 則通知\n }", - "user-offline": "使用者離線", - "user-offline-message": "{$source ->\n [streamer] 看起來 <1>@{$handle} 離線 了,但他們推薦觀看:\n *[default] 看起來 <1>@{$handle} 離線 了,但我們推薦觀看:\n }", - "user-offline-no-recommendations": "看起來 <1>@{$handle} 離線 了。\n請稍後再來看看。", - "streaming-title": "正在直播 {$title}", - "viewer-count": "{$count ->\n [0] 0 位觀眾\n [1] 1 位觀眾\n *[other] {$count} 位觀眾\n }", - "pds-selector-title": "初次接觸 Atmosphere?", - "pds-selector-description": "您需要選擇一個 PDS(個人資料伺服器)來存取 Atmosphere 上的應用程式,例如 Bluesky、Tangled 和 Spark。", - "pds-selector-custom-label": "其他 PDS", - "pds-selector-custom-description": "輸入您自己的 PDS 主機網址", - "pds-selector-custom-url-label": "自訂 PDS 網址", - "pds-selector-custom-url-placeholder": "https://pds.example.com", - "pds-selector-learn-more": "了解更多關於自行架設的資訊", - "pds-selector-info": "每個主機都有自己的政策和可靠性標準。您的 ATProto 資料存放在您選擇的主機上,您可以之後進行遷移。注意:Streamplace 有自己的審核規則——無論您選擇哪個主機,都可能被 Streamplace 封禁。", - "pds-selector-read-policies": "在繼續之前,請閱讀 {$label} 的服務條款和隱私政策。", - "pds-selector-handle-policy-checkbox": "我已閱讀並同意識別碼政策", - "login-show-live-on-bluesky": "在 Bluesky 上顯示我的直播狀態", - "login-show-live-on-bluesky-description": "直播時為您的 Bluesky 頭像加上紅色 LIVE 圓環,並允許 Streamplace 代您發布直播公告。取消勾選即可在不授予任何 Bluesky 帳號存取權限的情況下登入。" -} diff --git a/js/web/public/locales/zh-Hant/settings.json b/js/web/public/locales/zh-Hant/settings.json deleted file mode 100644 index 5c5990c52..000000000 --- a/js/web/public/locales/zh-Hant/settings.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "app-version": "播家 v{$version}", - "download-new-update": "下載新更新", - "check-for-updates": "檢查更新", - "bundled-runtype": "捆綁版", - "ota-runtype": "空中下載 (OTA)", - "recovery-runtype": "復原模式", - "modal-latest-version": "您正在使用最新版本。", - "modal-no-update-available": "您已經在使用最新版本的直播地,太棒了!", - "modal-update-available-title": "有可用更新", - "modal-update-available-description": "新版本的直播地已準備好下載", - "modal-update-failed": "更新檢查失敗。您可能需要透過 {$store} 更新應用程式。", - "modal-update-failed-title": "更新失敗", - "modal-update-failed-description": "更新檢查失敗。您可能需要透過 {$store} 更新應用程式。", - "button-reload-app-on-update": "套用更新 (將重新載入應用程式)", - "use-custom-node": "使用自訂節點", - "default-url": "預設:{$url}", - "enter-custom-node-url": "輸入自訂節點網址", - "save-button": "儲存", - "try-new-web": "試用新版網頁 (beta)", - "try-new-web-description": "切換到新的網頁介面。你可以隨時透過此設定切換回來。", - "try-old-app": "使用原版介面", - "try-old-app-description": "返回 Streamplace 原版介面。", - "language-selection": "語言", - "language-selection-description": "選擇您偏好的語言", - "help-translate": "幫助我們翻譯播家", - "help-translate-description": "我們正在尋找志願者協助將播家翻譯成更多語言。如果您有興趣,請在 Discord 或 GitHub 上與我們聯繫!", - "currently-translating": "翻譯正在進行中", - "currently-translating-description": "應用程式的某些部分可能看起來不完整。感謝您的耐心等待!", - "debug-recording-title": "允許 {$host} 錄製您的直播串流以進行除錯和服務改善", - "debug-recording-description": "可選項目", - "input-search-languages": "搜尋語言...", - "manage-keys": "管理金鑰", - "settings-title": "設定", - "loading": "載入中...", - "error": "錯誤", - "cancel": "取消", - "confirm": "確認", - "welcome-user": "歡迎,{$username}!", - "notification-count": "{$count ->\n [0] 無通知\n [1] 一則通知\n *[other] {$count} 則通知\n }", - "search-placeholder": "搜尋...", - "message-input": "請輸入您的訊息...", - "success": "成功", - "warning": "警告", - "info": "資訊", - "close": "關閉", - "open": "開啟", - "delete": "刪除", - "edit": "編輯", - "create": "建立", - "update": "更新", - "refresh": "重新整理", - "save": "儲存", - "cancel-button": "取消", - "ok": "確定", - "yes": "是", - "no": "否", - "continue": "繼續", - "back": "返回", - "next": "下一步", - "finish": "完成", - "about": "關於", - "account": "帳戶", - "advanced": "進階", - "danmu": "彈幕", - "developer": "開發者", - "languages": "語言", - "privacy-security": "隱私與安全", - "streaming": "串流", - "notifications": "通知", - "sign-in": "登入", - "log-out": "登出", - "account-greeting": "嗨,@{$handle}。", - "edit-profile-bluesky": "編輯個人資料(在 Bluesky)", - "change-name-color": "變更名稱顏色", - "key-management": "金鑰管理", - "key-manager": "金鑰管理器", - "your-stream-pubkeys": "您的串流公開金鑰", - "no-keys": "尚未設定金鑰", - "pubkey-description": "公開金鑰與串流金鑰(用於串流軟體)配對以簽署和驗證您的串流", - "keys-count": "{$count} 個金鑰", - "webhooks": "Webhooks", - "webhook-integrations": "Webhook 整合", - "webhook-integrations-description": "連接外部服務以即時接收有關您串流的更新", - "create-webhook": "建立 Webhook", - "edit-webhook": "編輯 Webhook", - "delete-webhook": "刪除 Webhook", - "no-webhooks-yet": "尚未設定 webhook", - "failed-load-webhooks": "載入 webhook 失敗", - "webhook-will-no-longer-receive-events": "此 webhook 將不再接收事件", - "create-first-webhook-description": "建立您的第一個 webhook 以開始接收串流事件", - "example-captain-hook": "虎克船長", - "webhooks-count": "{$count ->\n *[other] {$count} 個 webhook\n }", - "activates-on": "觸發於:", - "events-livestream": "直播串流事件", - "events-chat": "聊天事件", - "untitled-webhook": "未命名的 Webhook", - "inactive": "停用", - "debug-recording": "除錯錄製", - "danmu-enabled": "啟用彈幕", - "danmu-enabled-description": "將即時聊天訊息以浮動評論的形式顯示在您的螢幕上", - "danmu-opacity": "不透明度", - "danmu-speed": "速度", - "danmu-lane-count": "軌道數量", - "danmu-max-messages": "最大訊息數", - "app-version-description": "目前版本資訊", - "confirm-delete": "您確定要刪除嗎?", - "action-cannot-be-undone": "此動作無法復原", - "name-optional": "名稱(選填)", - "deleting": "刪除中...", - "saving": "儲存中...", - "go-to-dashboard": "前往控制台", - "need-setup-live-dashboard": "您需要先在控制台中設定直播串流", - "no-languages-found": "找不到語言", - "backup-save": "儲存備份設定", - "backup-saving": "正在儲存備份設定...", - "backup-secret-key-set-placeholder": "(密碼已設定)", - "backup-error-invalid-endpoint": "必須是有效的網域名稱", - "backup-error-invalid-bucket": "只能包含小寫字母、數字、點和連字號", - "backup-error-invalid-segment-duration": "必須介於 1 到 60 秒之間", - "backup-error-load-failed": "無法載入儲存設定", - "backup-error-update-failed": "更新備份狀態失敗", - "backup-error-save-failed": "儲存設定失敗", - "backup-error-missing-secret": "無法在沒有金鑰的情況下更新 S3 設定。請重新輸入。", - "backup-segment-duration-placeholder": "6", - "backup": "備份", - "backup-enabled": "S3 備份", - "backup-enabled-description": "自動將錄影備份到相容 S3 的儲存空間", - "backup-connection-url": "連線網址", - "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", - "backup-endpoint": "端點", - "backup-endpoint-placeholder": "s3.example.com", - "backup-bucket": "儲存桶", - "backup-bucket-placeholder": "my-backup-bucket", - "backup-access-key": "存取金鑰", - "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", - "backup-secret-key": "秘密金鑰", - "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "show-password-in-url": "在網址中顯示密碼", - "show-password-in-url-description": "在連線網址中顯示秘密金鑰(若已輸入)", - "recommendations-to-others": "向他人推薦主播", - "recommendations-description": "向您的觀眾推薦最多 8 位主播", - "no-recommendations-yet": "尚未設定推薦", - "multistream": "多重串流", - "multistream-targets": "多重串流目標", - "multistream-description": "自動將您的 Streamplace 直播推送到 Twitch 或 YouTube 等其他直播平台。", - "create-multistream-target": "建立多重串流目標", - "untitled-multistream-target": "未命名目標", - "failed-load-multistream-targets": "載入多重串流目標失敗。請重試。", - "failed-toggle-multistream-target": "切換多重串流目標失敗。請重試。", - "no-multistream-targets-yet": "還沒有目標!", - "multistream-targets-count": "{$count} 個目標", - "multistream-delete-target-confirmation": "您確定要刪除「{$target}」嗎?", - "this-action-cannot-be-undone": "此動作無法復原。", - "rtmp-target-name": "RTMP 目標", - "rtmp-target-url": "RTMP 網址", - "rtmp-target-name-placeholder": "我的多重串流目標", - "multistream-create-target": "建立目標", - "multistream-edit-target": "編輯目標", - "created": "建立於", - "status": "狀態", - "branding": "品牌識別", - "branding-admin": "品牌識別管理", - "branding-admin-description": "自訂您的 Streamplace 執行個體。請注意,設定可能需要幾小時才能生效。", - "branding-login-required": "請登入以管理品牌識別", - "branding-configuration": "設定", - "branding-text-settings": "文字設定", - "branding-colors": "顏色", - "branding-legal-links": "法律連結", - "branding-images": "圖像", - "branding-broadcaster-did": "主播 DID", - "branding-broadcaster-did-description": "留空以使用伺服器預設值", - "branding-site-title": "網站標題", - "branding-site-title-placeholder": "輸入新網站標題", - "branding-site-description": "網站描述", - "branding-site-description-placeholder": "輸入網站描述", - "branding-default-streamer": "預設主播", - "branding-default-streamer-none": "無", - "branding-default-streamer-placeholder": "did:plc:...", - "branding-clear-default-streamer": "清除預設主播", - "branding-primary-color": "主要顏色", - "branding-primary-color-placeholder": "#6366f1", - "branding-accent-color": "強調色", - "branding-accent-color-placeholder": "#8b5cf6", - "branding-main-logo": "主標誌", - "branding-main-logo-description": "SVG、PNG 或 JPEG(最大 500KB)", - "branding-favicon": "網站圖示", - "branding-favicon-description": "SVG、PNG 或 ICO(最大 100KB)", - "branding-sidebar-bg": "側邊欄背景圖片", - "branding-sidebar-bg-description": "SVG、PNG 或 JPEG(最大 500KB)——顯示在側邊欄底部,全寬顯示。為了最佳效果,請上傳帶有透明度的圖片,因為目前沒有單獨的透明度選項。", - "branding-current": "目前:{$value}", - "branding-dimensions": "{$height} x {$width}", - "branding-upload-logo": "上傳標誌", - "branding-delete-logo": "刪除標誌", - "branding-upload-favicon": "上傳網站圖示", - "branding-delete-favicon": "刪除網站圖示", - "branding-upload-background": "上傳背景", - "branding-delete-background": "刪除背景", - "branding-web-only": "圖片上傳僅在網頁版可用。", - "refresh-branding": "更新品牌識別資源", - "branding-add-legal-link": "新增法律連結", - "branding-edit-legal-link": "編輯法律連結", - "branding-legal-link-text-placeholder": "連結文字(例如:隱私政策)", - "branding-legal-link-url-placeholder": "網址(例如:https://example.com/privacy)", - "add": "新增", - "active": "啟用", - "optional": "選填", - "branding-not-authenticated": "請先登入", - "branding-empty-value": "請輸入一個值", - "branding-update-success": "{$key} 更新成功", - "branding-upload-success": "{$key} 上傳成功", - "branding-delete-success": "{$key} 刪除成功", - "branding-upload-failed": "上傳失敗", - "branding-delete-failed": "刪除失敗", - "branding-not-available": "檔案上傳僅在網頁版可用", - "node-legal-documents": "主播專屬文件", - "badges": "徽章", - "badges-cosmetic-section": "裝飾性徽章", - "badges-empty-state": "您尚未獲得任何徽章。", - "badges-failed-load": "載入徽章失敗", - "badges-failed-update": "更新徽章選擇失敗", - "badges-issued-by": "由 {$issuer} 頒發", - "badges-streamer-section": "主播徽章", - "issue-badges": "頒發徽章", - "issue-badges-back-to-definitions": "徽章定義", - "issue-badges-badge-name": "徽章名稱", - "issue-badges-badge-name-placeholder": "例如 VIP、支持者", - "issue-badges-badge-type": "徽章類型", - "issue-badges-choose-image": "選擇圖片", - "issue-badges-create-definition": "建立徽章定義", - "issue-badges-create-definition-description": "定義可頒發給觀眾的新徽章類型", - "issue-badges-create-definition-subtitle": "定義新徽章類型", - "issue-badges-definition-created": "徽章定義已建立", - "issue-badges-description-optional": "描述(選填)", - "issue-badges-description-placeholder": "此徽章的選填描述", - "issue-badges-failed-create": "建立徽章定義失敗", - "issue-badges-failed-issue": "頒發徽章失敗", - "issue-badges-failed-load": "載入徽章定義失敗", - "issue-badges-image-optional": "圖片(選填)", - "issue-badges-image-too-large": "圖片必須小於 256KB", - "issue-badges-image-web-only": "圖片上傳僅在網頁版可用", - "issue-badges-issue-badge": "頒發徽章", - "issue-badges-issue-badge-description": "透過 DID 將「{$name}」徽章頒發給觀眾", - "issue-badges-issued": "徽章已頒發", - "issue-badges-issued-to": "已頒發給 {$did}", - "issue-badges-manage-description": "建立徽章定義並頒發給觀眾", - "issue-badges-recipient-did": "收件者 DID", - "issue-badges-recipient-did-placeholder": "did:plc:...", - "issue-badges-tap-to-issue": "點擊頒發", - "issue-badges-your-definitions": "您的徽章定義" -} diff --git a/js/web/src/components/stream/stream-notification-provider.tsx b/js/web/src/components/stream/stream-notification-provider.tsx new file mode 100644 index 000000000..d9f14cf3d --- /dev/null +++ b/js/web/src/components/stream/stream-notification-provider.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import { + streamNotificationManager, + type StreamNotification, +} from "../../lib/stream-notification"; + +export function StreamNotificationProvider({ + position = "top", +}: { + position?: "top" | "bottom"; +}) { + const [notifications, setNotifications] = useState( + streamNotificationManager.getAll(), + ); + + useEffect(() => streamNotificationManager.subscribe(setNotifications), []); + + return ( +
+ {notifications.map((notification) => ( + + ))} +
+ ); +} + +function NotificationItem({ + notification, + position, +}: { + notification: StreamNotification; + position: "top" | "bottom"; +}) { + return ( +
+ {notification.render ? ( + notification.render() + ) : ( +

{notification.message}

+ )} +
+ ); +} diff --git a/js/web/src/components/stream/stream-notifications.tsx b/js/web/src/components/stream/stream-notifications.tsx index b0315d367..fea05cdb0 100644 --- a/js/web/src/components/stream/stream-notifications.tsx +++ b/js/web/src/components/stream/stream-notifications.tsx @@ -20,6 +20,7 @@ import useAvatars from "../../hooks/use-avatars"; import { useCanModerate } from "../../hooks/use-can-moderate"; import { useModerationActions } from "../../hooks/use-moderation-actions"; import { useSession } from "../../lib/session"; +import { streamNotification } from "../../lib/stream-notification"; function rgbColor( color?: { red: number; green: number; blue: number } | null, @@ -61,8 +62,22 @@ export function StreamTeleportNotification({ store: LivestreamStore; }) { const teleport = useStore(store, (s) => s.activeTeleport); - if (!teleport) return null; - return ; + + useEffect(() => { + if (!teleport) { + streamNotification.hide("teleport"); + return; + } + + streamNotification.show({ + id: "teleport", + duration: 0, + render: () => , + }); + return () => streamNotification.hide("teleport"); + }, [store, teleport]); + + return null; } function PinnedNotification({ @@ -262,6 +277,10 @@ function TeleportNotification({ const diff = Math.max(0, Math.ceil((startsAt - now) / 1000)); + useEffect(() => { + if (diff <= 0) streamNotification.hide("teleport"); + }, [diff]); + if (diff <= 0) return null; const mins = Math.floor(diff / 60); diff --git a/js/web/src/components/stream/video-section.tsx b/js/web/src/components/stream/video-section.tsx index 0555e50b7..dde463216 100644 --- a/js/web/src/components/stream/video-section.tsx +++ b/js/web/src/components/stream/video-section.tsx @@ -11,6 +11,7 @@ import { useStore as useAppStore } from "../../lib/store"; import { getStreamplaceUrl } from "../../lib/streamplace-url"; import { DanmuOverlay } from "./danmu-overlay"; import { PlayerOffline } from "./player-offline"; +import { StreamNotificationProvider } from "./stream-notification-provider"; import { UserOffline } from "./user-offline"; const DANMU_KEY = "danmu-enabled"; @@ -243,6 +244,8 @@ export function VideoSectionInner({ {p.message}
))} + + {mode === "live" && store && }
); diff --git a/js/web/src/lib/i18n.ts b/js/web/src/lib/i18n.ts index 0677bf7fa..01ada437d 100644 --- a/js/web/src/lib/i18n.ts +++ b/js/web/src/lib/i18n.ts @@ -6,6 +6,7 @@ import { esESSettings, frFRCommon, frFRSettings, + getSupportedLocales, ptBRCommon, ptBRSettings, roROCommon, @@ -36,6 +37,7 @@ i18next .use(i18nextFluent) .init({ ...baseConfig, + preload: getSupportedLocales(), resources, } as Parameters[0]); diff --git a/js/web/src/lib/stream-notification.ts b/js/web/src/lib/stream-notification.ts new file mode 100644 index 000000000..7ec334263 --- /dev/null +++ b/js/web/src/lib/stream-notification.ts @@ -0,0 +1,83 @@ +import type { ReactNode } from "react"; + +export type StreamNotificationConfig = { + id: string; + message?: string; + render?: () => ReactNode; + duration?: number; +}; + +export type StreamNotification = StreamNotificationConfig & { + startTime: number; +}; + +class StreamNotificationManager { + private notifications: StreamNotification[] = []; + private listeners = new Set<(notifications: StreamNotification[]) => void>(); + private dismissTimers = new Map>(); + + show(config: StreamNotificationConfig) { + const timer = this.dismissTimers.get(config.id); + if (timer) { + clearTimeout(timer); + this.dismissTimers.delete(config.id); + } + + this.notifications = this.notifications.filter( + (notification) => notification.id !== config.id, + ); + const notification = { + ...config, + duration: config.duration ?? 5, + startTime: Date.now(), + }; + this.notifications = [...this.notifications, notification]; + this.notifyListeners(); + + if (notification.duration > 0) { + this.dismissTimers.set( + config.id, + setTimeout(() => this.hide(config.id), notification.duration * 1000), + ); + } + } + + hide(id: string) { + const timer = this.dismissTimers.get(id); + if (timer) { + clearTimeout(timer); + this.dismissTimers.delete(id); + } + + const remaining = this.notifications.filter( + (notification) => notification.id !== id, + ); + if (remaining.length === this.notifications.length) return; + this.notifications = remaining; + this.notifyListeners(); + } + + getAll() { + return this.notifications; + } + + subscribe(listener: (notifications: StreamNotification[]) => void) { + this.listeners.add(listener); + listener(this.notifications); + return () => { + this.listeners.delete(listener); + }; + } + + private notifyListeners() { + this.listeners.forEach((listener) => listener(this.notifications)); + } +} + +export const streamNotificationManager = new StreamNotificationManager(); + +export const streamNotification = { + show: (config: StreamNotificationConfig) => + streamNotificationManager.show(config), + hide: (id: string) => streamNotificationManager.hide(id), +}; -- 2.54.0 From ad70370816f67daf90b5ef2fdf73167ae39c0b4b Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Wed, 30 Sep 2026 17:27:29 -0500 Subject: [PATCH 60/76] misc followback and other random stuff Signed-off-by: Natalie Bridgers --- js/core/src/slash-commands/teleport.ts | 13 +- js/i18n/locales/en-US/common.ftl | 2 +- .../src/components/dashboard/chat-panel.tsx | 20 +- js/web/src/components/stream/chat-input.tsx | 15 +- js/web/src/components/stream/stream-info.tsx | 44 +++- js/web/src/hooks/use-follow.test.tsx | 243 ++++++++++++++++++ js/web/src/hooks/use-follow.ts | 66 +++++ js/web/src/lib/store/slices/blueskySlice.ts | 44 ---- js/web/src/styles.css | 4 +- 9 files changed, 378 insertions(+), 73 deletions(-) create mode 100644 js/web/src/hooks/use-follow.test.tsx create mode 100644 js/web/src/hooks/use-follow.ts diff --git a/js/core/src/slash-commands/teleport.ts b/js/core/src/slash-commands/teleport.ts index ebd6f2a1f..0349686f4 100644 --- a/js/core/src/slash-commands/teleport.ts +++ b/js/core/src/slash-commands/teleport.ts @@ -26,7 +26,7 @@ export async function createTeleport( targetHandle: string, countdownSeconds: number, livestream?: { uri: string; cid: string } | null, - setActiveTeleportUri?: (uri: string | null) => void, + onTeleportCreated?: (uri: string, targetDID: string) => void, ): Promise<{ success: boolean; error?: string }> { if (countdownSeconds < 5 || countdownSeconds > 300) { return { @@ -76,9 +76,7 @@ export async function createTeleport( { repo: userDID as any }, ); - if (setActiveTeleportUri) { - setActiveTeleportUri(result.uri); - } + onTeleportCreated?.(result.uri, targetDID); return { success: true }; } catch (err) { @@ -97,7 +95,7 @@ export function registerTeleportCommand( cid: string; streamerDid: string; } | null, - setActiveTeleportUri?: (uri: string | null) => void, + onTeleportCreated?: (uri: string, targetDID: string) => void, onOpenModal?: () => void, ): () => void { const teleportHandler: SlashCommandHandler = async ( @@ -198,10 +196,7 @@ export function registerTeleportCommand( { repo: userDID as any }, ); - // store the URI in the livestream store - if (setActiveTeleportUri) { - setActiveTeleportUri(result.uri); - } + onTeleportCreated?.(result.uri, targetDID); return { handled: true }; } catch (err) { diff --git a/js/i18n/locales/en-US/common.ftl b/js/i18n/locales/en-US/common.ftl index 918fa366a..1e94f5171 100644 --- a/js/i18n/locales/en-US/common.ftl +++ b/js/i18n/locales/en-US/common.ftl @@ -430,7 +430,7 @@ player-latency = Latency player-quality-auto = Auto ## Video Player - Stats panel -player-stats = Player stats +player-stats = Stats for Nerds player-stats-resolution = Resolution player-stats-viewport = Viewport player-stats-bitrate = Bitrate diff --git a/js/web/src/components/dashboard/chat-panel.tsx b/js/web/src/components/dashboard/chat-panel.tsx index 0d82505d9..5dbdb2fa1 100644 --- a/js/web/src/components/dashboard/chat-panel.tsx +++ b/js/web/src/components/dashboard/chat-panel.tsx @@ -1,4 +1,6 @@ +import { useToast } from "@/hooks/use-toast"; import type { LivestreamStore } from "@streamplace/core"; +import { useNavigate } from "@tanstack/react-router"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useStore } from "zustand"; @@ -12,6 +14,8 @@ import { ChatPanel as ChatMessages } from "../stream/chat-panel"; */ export function ChatPanelWidget({ store }: { store: LivestreamStore }) { const { t } = useTranslation("common"); + const toast = useToast(); + const navigate = useNavigate(); const state = useStore( store, useShallow((s) => ({ @@ -46,7 +50,21 @@ export function ChatPanelWidget({ store }: { store: LivestreamStore }) { {/* Input */}
- + { + toast.show("Teleport started", "Visit the receiving livestream.", { + variant: "success", + actionLabel: "Go to livestream", + onAction: () => { + void navigate({ + to: "/$user", + params: { user: targetDID }, + }); + }, + }); + }} + />
); diff --git a/js/web/src/components/stream/chat-input.tsx b/js/web/src/components/stream/chat-input.tsx index 865df37bb..0f5d990a3 100644 --- a/js/web/src/components/stream/chat-input.tsx +++ b/js/web/src/components/stream/chat-input.tsx @@ -299,7 +299,13 @@ function createEmojiSuggestion({ getSkinTone }: EmojiSuggestionProps) { }; } -export function ChatInput({ store }: { store: LivestreamStore }) { +export function ChatInput({ + store, + onTeleportCreated, +}: { + store: LivestreamStore; + onTeleportCreated?: (uri: string, targetDID: string) => void; +}) { const { t } = useTranslation("common"); const { state, pdsAgent, did } = useSession(); const isAuthed = state.status === "authenticated"; @@ -327,10 +333,13 @@ export function ChatInput({ store }: { store: LivestreamStore }) { streamerDid: livestream.author.did, } : null, - (uri) => store.setState({ activeTeleportUri: uri }), + (uri, targetDID) => { + store.setState({ activeTeleportUri: uri }); + onTeleportCreated?.(uri, targetDID); + }, () => setTeleportOpen(true), ); - }, [did, livestream, pdsAgent, store]); + }, [did, livestream, pdsAgent, store, onTeleportCreated]); // Keep module-level refs fresh so the suggestion items functions always // have up-to-date data without recreating the editor. diff --git a/js/web/src/components/stream/stream-info.tsx b/js/web/src/components/stream/stream-info.tsx index 0dcbac4b8..17ceff54d 100644 --- a/js/web/src/components/stream/stream-info.tsx +++ b/js/web/src/components/stream/stream-info.tsx @@ -1,5 +1,4 @@ import { useToast } from "@/hooks/use-toast"; -import { useStore } from "@/lib/store"; import { useStreamplaceUrl } from "@/lib/store/hooks"; import { cn } from "@/lib/utils"; import type { LivestreamStore } from "@streamplace/core"; @@ -17,6 +16,7 @@ import { place } from "streamplace"; import { useStore as useLivestreamStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; import { useCanModerate } from "../../hooks/use-can-moderate"; +import { useFollow } from "../../hooks/use-follow"; import type { Liveness } from "../../hooks/use-liveness-state"; import { useModerationActions } from "../../hooks/use-moderation-actions"; import { useSession } from "../../lib/session"; @@ -93,8 +93,8 @@ export function StreamInfo({ ); const { state: sessionState } = useSession(); - const followUser = useStore((s) => s.followUser); - const [following, setFollowing] = useState(false); + const follow = useFollow(state.livestream?.author.did); + const toast = useToast(); const record = state.livestream?.record; const author = state.livestream?.author; const title = record?.title || user; @@ -107,14 +107,14 @@ export function StreamInfo({ const node = useStreamplaceUrl(); const handleFollow = async () => { - if (!author?.did) return; - setFollowing(true); try { - await followUser(author.did); - } catch (e) { - console.error("Failed to follow:", e); - } finally { - setFollowing(false); + await follow.toggle(); + } catch (error) { + toast.show( + "Failed to update follow", + error instanceof Error ? error.message : "Please try again.", + { variant: "error" }, + ); } }; @@ -181,14 +181,32 @@ export function StreamInfo({
{sessionState.status === "authenticated" && - sessionState.session.did !== author?.did && ( + author?.did && + sessionState.session.did !== author.did && ( )} diff --git a/js/web/src/hooks/use-follow.test.tsx b/js/web/src/hooks/use-follow.test.tsx new file mode 100644 index 000000000..48f34d3b6 --- /dev/null +++ b/js/web/src/hooks/use-follow.test.tsx @@ -0,0 +1,243 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useFollow } from "./use-follow"; + +const { mockUseSession } = vi.hoisted(() => ({ mockUseSession: vi.fn() })); +vi.mock("@/lib/session", () => ({ useSession: mockUseSession })); +vi.mock("@/lib/store", () => ({ + useStore: (selector: (state: { url: string }) => unknown) => + selector({ url: "https://node.example" }), +})); + +const VIEWER = "did:plc:viewer"; +const STREAMER = "did:plc:streamer"; +const FOLLOW_URI = `at://${VIEWER}/app.bsky.graph.follow/record`; + +function response(uri: string | null) { + return new Response(JSON.stringify(uri ? { follow: { uri } } : {})); +} + +describe("useFollow", () => { + let container: HTMLDivElement; + let root: Root; + let client: QueryClient; + let current: ReturnType; + let fetchMock: ReturnType; + let agent: { + follow: ReturnType; + deleteFollow: ReturnType; + }; + + function Probe({ subject }: { subject?: string }) { + current = useFollow(subject); + return {current.following ? "Following" : "Follow"}; + } + + async function render(subject: string | undefined = STREAMER) { + await act(async () => { + root.render( + + + , + ); + }); + } + + async function waitForLoaded() { + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(current.loading).toBe(false); + } + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + fetchMock = vi.fn().mockResolvedValue(response(null)); + vi.stubGlobal("fetch", fetchMock); + agent = { + follow: vi.fn().mockResolvedValue({ uri: FOLLOW_URI }), + deleteFollow: vi.fn().mockResolvedValue(undefined), + }; + mockUseSession.mockReturnValue({ did: VIEWER, pdsAgent: agent }); + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + mockUseSession.mockReset(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("loads an existing follow record for the viewer and streamer", async () => { + fetchMock.mockResolvedValue(response(FOLLOW_URI)); + await render(); + await waitForLoaded(); + + expect(container.textContent).toBe("Following"); + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.origin).toBe("https://node.example"); + expect(url.searchParams.get("userDID")).toBe(VIEWER); + expect(url.searchParams.get("subjectDID")).toBe(STREAMER); + await act(async () => current.toggle()); + expect(agent.deleteFollow).toHaveBeenCalledWith(FOLLOW_URI); + expect(container.textContent).toBe("Follow"); + }); + + it("keeps the returned URI after following so the next click unfollows", async () => { + await render(); + await waitForLoaded(); + expect(container.textContent).toBe("Follow"); + + await act(async () => current.toggle()); + expect(agent.follow).toHaveBeenCalledWith(STREAMER); + expect(container.textContent).toBe("Following"); + await act(async () => current.toggle()); + expect(agent.deleteFollow).toHaveBeenCalledWith(FOLLOW_URI); + expect(container.textContent).toBe("Follow"); + }); + + it("does not allow a follow before the lookup completes", async () => { + fetchMock.mockReturnValue(new Promise(() => {})); + await render(); + expect(current.loading).toBe(true); + await act(async () => current.toggle()); + expect(agent.follow).not.toHaveBeenCalled(); + }); + + it("keeps lookup errors distinct from not following", async () => { + fetchMock.mockResolvedValue(new Response("failed", { status: 500 })); + await render(); + await waitForLoaded(); + expect(current.error).toBeInstanceOf(Error); + await act(async () => current.toggle()); + expect(agent.follow).not.toHaveBeenCalled(); + }); + + it("preserves the record when unfollow fails", async () => { + fetchMock.mockResolvedValue(response(FOLLOW_URI)); + const error = new Error("write failed"); + agent.deleteFollow.mockRejectedValue(error); + await render(); + await waitForLoaded(); + await act(async () => { + await expect(current.toggle()).rejects.toBe(error); + }); + expect(container.textContent).toBe("Following"); + expect(current.loading).toBe(false); + }); + + it("preserves not-following state when follow fails", async () => { + const error = new Error("write failed"); + agent.follow.mockRejectedValue(error); + await render(); + await waitForLoaded(); + await act(async () => { + await expect(current.toggle()).rejects.toBe(error); + }); + expect(container.textContent).toBe("Follow"); + expect(current.loading).toBe(false); + }); + + it("blocks another toggle while a write is pending", async () => { + let resolve!: (value: { uri: string }) => void; + agent.follow.mockImplementation( + () => + new Promise<{ uri: string }>((done) => { + resolve = done; + }), + ); + await render(); + await waitForLoaded(); + let write!: Promise; + await act(async () => { + write = current.toggle(); + }); + expect(current.loading).toBe(true); + await act(async () => current.toggle()); + expect(agent.follow).toHaveBeenCalledTimes(1); + await act(async () => { + resolve({ uri: FOLLOW_URI }); + await write; + }); + expect(container.textContent).toBe("Following"); + }); + + it("does not apply a late follow write to a different streamer", async () => { + fetchMock.mockImplementation(async () => response(null)); + let resolve!: (value: { uri: string }) => void; + agent.follow.mockImplementation( + () => + new Promise<{ uri: string }>((done) => { + resolve = done; + }), + ); + await render(); + await waitForLoaded(); + let write!: Promise; + await act(async () => { + write = current.toggle(); + }); + await render("did:plc:other"); + await act(async () => { + resolve({ uri: FOLLOW_URI }); + await write; + await vi.runAllTimersAsync(); + }); + expect(container.textContent).toBe("Follow"); + expect( + client.getQueryData(["follow", "https://node.example", VIEWER, STREAMER]), + ).toBe(FOLLOW_URI); + }); + + it("does not apply a late lookup to a different streamer", async () => { + let resolve!: (value: Response) => void; + fetchMock.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + await render(); + await render("did:plc:other"); + await waitForLoaded(); + await act(async () => resolve(response(FOLLOW_URI))); + expect(container.textContent).toBe("Follow"); + }); + + it("does not reuse another viewer's follow state", async () => { + fetchMock.mockResolvedValueOnce(response(FOLLOW_URI)); + await render(); + await waitForLoaded(); + mockUseSession.mockReturnValue({ + did: "did:plc:other-viewer", + pdsAgent: agent, + }); + await render(); + await waitForLoaded(); + expect(container.textContent).toBe("Follow"); + const url = new URL(fetchMock.mock.calls.at(-1)![0]); + expect(url.searchParams.get("userDID")).toBe("did:plc:other-viewer"); + }); + + it("skips lookups and writes when logged out or viewing yourself", async () => { + mockUseSession.mockReturnValue({ did: null, pdsAgent: null }); + await render(); + await act(async () => current.toggle()); + mockUseSession.mockReturnValue({ did: VIEWER, pdsAgent: agent }); + await render(VIEWER); + await act(async () => current.toggle()); + expect(fetchMock).not.toHaveBeenCalled(); + expect(agent.follow).not.toHaveBeenCalled(); + }); +}); diff --git a/js/web/src/hooks/use-follow.ts b/js/web/src/hooks/use-follow.ts new file mode 100644 index 000000000..b0e63bec5 --- /dev/null +++ b/js/web/src/hooks/use-follow.ts @@ -0,0 +1,66 @@ +import { useSession } from "@/lib/session"; +import { useStore } from "@/lib/store"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +export function useFollow(subjectDID: string | undefined) { + const { did, pdsAgent } = useSession(); + const node = useStore((state) => state.url); + const queryClient = useQueryClient(); + const [pending, setPending] = useState(false); + const enabled = !!pdsAgent && !!did && !!subjectDID && did !== subjectDID; + const queryKey = ["follow", node, did, subjectDID]; + const query = useQuery({ + queryKey, + enabled, + queryFn: async ({ signal }): Promise => { + const params = new URLSearchParams({ + userDID: did!, + subjectDID: subjectDID!, + }); + const response = await fetch( + `${node}/xrpc/place.stream.graph.getFollowingUser?${params}`, + { credentials: "include", signal }, + ); + if (!response.ok) throw new Error("Failed to load follow status"); + const data: { follow?: { uri: string } } = await response.json(); + return data.follow?.uri ?? null; + }, + }); + + const toggle = async () => { + if ( + !enabled || + !pdsAgent || + !subjectDID || + query.data === undefined || + query.isFetching || + query.isError || + pending + ) { + return; + } + + setPending(true); + try { + await queryClient.cancelQueries({ queryKey }); + if (query.data) { + await pdsAgent.deleteFollow(query.data); + queryClient.setQueryData(queryKey, null); + } else { + const result = await pdsAgent.follow(subjectDID); + // Use the write result rather than waiting for the node to index it. + queryClient.setQueryData(queryKey, result.uri); + } + } finally { + setPending(false); + } + }; + + return { + following: !!query.data, + loading: enabled && (query.isPending || query.isFetching || pending), + error: query.error, + toggle, + }; +} diff --git a/js/web/src/lib/store/slices/blueskySlice.ts b/js/web/src/lib/store/slices/blueskySlice.ts index 09e027053..980ec7aad 100644 --- a/js/web/src/lib/store/slices/blueskySlice.ts +++ b/js/web/src/lib/store/slices/blueskySlice.ts @@ -88,8 +88,6 @@ export interface BlueskySlice { blue: number, selfLabels?: string[], ) => Promise; - followUser: (subjectDID: string) => Promise; - unfollowUser: (subjectDID: string, followUri?: string) => Promise; getServerSettingsFromPDS: () => Promise; createServerSettingsRecord: ( patch: Partial>, @@ -805,48 +803,6 @@ export const createBlueskySlice: StateCreator< } }, - followUser: async (subjectDID: string) => { - const state = get() as BlueskySlice; - if (!state.pdsAgent) { - throw new Error("No agent"); - } - const did = state.oauthSession?.did; - if (!did) { - throw new Error("No DID"); - } - await state.pdsAgent.follow(subjectDID); - }, - - unfollowUser: async (subjectDID: string, followUri?: string) => { - const state = get() as BlueskySlice; - if (!state.pdsAgent) { - throw new Error("No agent"); - } - const did = state.oauthSession?.did; - if (!did) { - throw new Error("No DID"); - } - - if (followUri) { - await state.pdsAgent.deleteFollow(followUri); - } else { - const streamplaceUrl = get().url; - const res = await fetch( - `${streamplaceUrl}/xrpc/place.stream.graph.getFollowingUser?subjectDID=${encodeURIComponent(subjectDID)}&userDID=${encodeURIComponent(did)}`, - { - credentials: "include", - }, - ); - const data = await res.json(); - - if (!data.follow || !data.follow.uri) { - throw new Error("Follow record not found"); - } - - await state.pdsAgent.deleteFollow(data.follow.uri); - } - }, - getServerSettingsFromPDS: async () => { const state = get() as BlueskySlice; const did = state.oauthSession?.did; diff --git a/js/web/src/styles.css b/js/web/src/styles.css index 2d21ff0dc..fda9cbee4 100644 --- a/js/web/src/styles.css +++ b/js/web/src/styles.css @@ -56,7 +56,7 @@ code { .dark { --background: oklch(0.18 0.03 308.5); --foreground: oklch(0.9498 0.0183 336.6); - --card: oklch(0.2432 0.03 326.7); + --card: oklch(0.2432 0.0375 326.7); --card-foreground: oklch(0.9498 0.0183 336.6); --popover: oklch(0.2123 0.0376 318); --popover-foreground: oklch(0.9498 0.0183 336.6); @@ -78,7 +78,7 @@ code { --chart-3: oklch(0.46 0.08 250); --chart-4: oklch(0.48 0.035 65); --chart-5: oklch(0.44 0.045 155); - --sidebar: oklch(0.26 0.02 326.7); + --sidebar: oklch(0.2432 0.0375 326.7); --sidebar-foreground: oklch(0.9498 0.0183 336.6); --sidebar-primary: oklch(0.6803 0.2158 339.7); --sidebar-primary-foreground: oklch(0.1841 0.0279 308.5); -- 2.54.0 From 0dd561da30b795370ab742363dc6aea7c28718d8 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Wed, 30 Sep 2026 17:36:29 -0500 Subject: [PATCH 61/76] Update web UI theme tokens and add recursive token validation test Signed-off-by: Natalie Bridgers --- js/web/src/components/dashboard/stream-info.tsx | 2 +- js/web/src/components/ui/dropdown-menu.tsx | 10 +++++----- js/web/src/components/ui/select.tsx | 2 +- js/web/src/styles.css | 1 - js/web/src/styles.test.ts | 14 ++++++++++++++ 5 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 js/web/src/styles.test.ts diff --git a/js/web/src/components/dashboard/stream-info.tsx b/js/web/src/components/dashboard/stream-info.tsx index c62da5613..c76abd67a 100644 --- a/js/web/src/components/dashboard/stream-info.tsx +++ b/js/web/src/components/dashboard/stream-info.tsx @@ -358,7 +358,7 @@ export function StreamInfoWidget({ store }: { store: LivestreamStore }) { diff --git a/js/web/src/components/ui/dropdown-menu.tsx b/js/web/src/components/ui/dropdown-menu.tsx index fd2cd08b0..587fe0844 100644 --- a/js/web/src/components/ui/dropdown-menu.tsx +++ b/js/web/src/components/ui/dropdown-menu.tsx @@ -92,7 +92,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "group/dropdown-menu-item data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/dropdown-menu-item data-highlighted:bg-muted data-highlighted:text-foreground not-data-[variant=destructive]:data-highlighted:**:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} @@ -117,7 +117,7 @@ function DropdownMenuSubTrigger({ data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn( - "data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "data-highlighted:bg-muted data-highlighted:text-foreground not-data-[variant=destructive]:data-highlighted:**:text-foreground data-popup-open:bg-muted data-popup-open:text-foreground data-open:bg-muted data-open:text-foreground flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} @@ -166,7 +166,7 @@ function DropdownMenuCheckboxItem({ data-slot="dropdown-menu-checkbox-item" data-inset={inset} className={cn( - "data-highlighted:bg-accent data-highlighted:text-accent-foreground data-highlighted:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "data-highlighted:bg-muted data-highlighted:text-foreground data-highlighted:**:text-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} checked={checked} @@ -207,7 +207,7 @@ function DropdownMenuRadioItem({ data-slot="dropdown-menu-radio-item" data-inset={inset} className={cn( - "data-highlighted:bg-accent data-highlighted:text-accent-foreground data-highlighted:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "data-highlighted:bg-muted data-highlighted:text-foreground data-highlighted:**:text-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} @@ -246,7 +246,7 @@ function DropdownMenuShortcut({ { + const css = readFileSync("src/styles.css", "utf8"); + const aliases = [...css.matchAll(/(--[\w-]+):\s*var\((--[\w-]+)\)\s*;/g)]; + expect(aliases.length).toBeGreaterThan(0); + // A self-reference invalidates the token, leaving highlight backgrounds + // transparent even when the paired foreground color is applied. + const selfReferences = aliases + .filter(([, name, target]) => name === target) + .map(([, name]) => name); + expect(selfReferences).toEqual([]); +}); -- 2.54.0 From b60f5b514918be9f61316778dcf4e6a43e7ca5b0 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Wed, 30 Sep 2026 17:47:27 -0500 Subject: [PATCH 62/76] revert(atproto): remove validation as it blocks out older version teleports Signed-off-by: Natalie Bridgers --- pkg/atproto/sync.go | 8 ------- pkg/atproto/teleport_endstream.go | 35 ------------------------------- pkg/atproto/teleport_test.go | 19 ----------------- 3 files changed, 62 deletions(-) diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index 0c8baf139..f0381f39c 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -623,14 +623,6 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD if r == nil { return nil } - sourceIsLive, err := atsync.teleportSourceIsLive(userDID, rec.Livestream) - if err != nil { - return fmt.Errorf("failed to validate teleport source stream: %w", err) - } - if !sourceIsLive { - log.Debug(ctx, "ignoring teleport without an active source stream owned by its author") - return nil - } startsAt, err := time.Parse(time.RFC3339, rec.StartsAt) if err != nil { log.Error(ctx, "failed to parse startsAt", "err", err) diff --git a/pkg/atproto/teleport_endstream.go b/pkg/atproto/teleport_endstream.go index 08a6a0d88..858c6d513 100644 --- a/pkg/atproto/teleport_endstream.go +++ b/pkg/atproto/teleport_endstream.go @@ -19,41 +19,6 @@ import ( comatproto "stream.place/streamplace/pkg/comatproto" ) -// teleportSourceIsLive confirms that a new teleport is authored by the owner -// of the source stream and references that owner's latest, unended stream. -func (atsync *ATProtoSynchronizer) teleportSourceIsLive( - repoDID string, - livestreamRef *comatproto.RepoStrongRef, -) (bool, error) { - if livestreamRef == nil || livestreamRef.Uri == "" || livestreamRef.Cid == "" { - return false, nil - } - - aturi, err := syntax.ParseATURI(livestreamRef.Uri) - if err != nil || - aturi.Authority().String() != repoDID || - aturi.Collection().String() != "place.stream.livestream" { - return false, nil - } - - livestream, err := atsync.Model.GetLatestLivestreamForRepo(repoDID) - if err != nil { - return false, fmt.Errorf("get source livestream: %w", err) - } - if livestream == nil || - livestream.URI != livestreamRef.Uri || - livestream.CID != livestreamRef.Cid || - livestream.Livestream == nil { - return false, nil - } - - var record placestream.Livestream - if err := glex.DecodeCBOR(*livestream.Livestream, &record); err != nil { - return false, fmt.Errorf("decode source livestream: %w", err) - } - return record.EndedAt == nil, nil -} - // endLivestreamForTeleport ends the source streamer's livestream when a teleport // fires — the same record update that place.stream.live.stopLivestream performs // (set endedAt on the place.stream.livestream record via a getRecord/putRecord diff --git a/pkg/atproto/teleport_test.go b/pkg/atproto/teleport_test.go index 952ca5950..820c2e88e 100644 --- a/pkg/atproto/teleport_test.go +++ b/pkg/atproto/teleport_test.go @@ -10,7 +10,6 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "github.com/stretchr/testify/require" "stream.place/streamplace/pkg/bus" - "stream.place/streamplace/pkg/comatproto" "stream.place/streamplace/pkg/model" "stream.place/streamplace/pkg/placestream" "stream.place/streamplace/pkg/spid" @@ -37,20 +36,6 @@ func TestTeleportArrivalNotFromBackfill(t *testing.T) { Version: "3lrev00000000", })) - sourceStream := &placestream.Livestream{ - LexiconTypeID: "place.stream.livestream", - CreatedAt: time.Now().UTC().Format(time.RFC3339), - Title: "Source stream", - } - var sourceStreamBuffer bytes.Buffer - require.NoError(t, sourceStream.MarshalCBOR(&sourceStreamBuffer)) - sourceStreamCBOR := sourceStreamBuffer.Bytes() - sourceStreamCID, err := spid.GetCID(sourceStream) - require.NoError(t, err) - sourceStreamURI := "at://" + traveller + "/place.stream.livestream/3lsourcestream" - require.NoError(t, atsync.handleCreateUpdate(ctx, traveller, syntax.RecordKey("3lsourcestream"), - &sourceStreamCBOR, sourceStreamCID.String(), syntax.NSID("place.stream.livestream"), false, false, "")) - // Watch the streamer's topic, which is where an arrival is announced. ch := b.Subscribe(streamer) defer b.Unsubscribe(streamer, ch) @@ -77,10 +62,6 @@ func TestTeleportArrivalNotFromBackfill(t *testing.T) { Streamer: streamer, StartsAt: startsAt, DurationSeconds: &duration, - Livestream: &comatproto.RepoStrongRef{ - Uri: sourceStreamURI, - Cid: sourceStreamCID.String(), - }, } var buf bytes.Buffer require.NoError(t, rec.MarshalCBOR(&buf)) -- 2.54.0 From 8bcf12a3b8775204c1616df75e6388f960bb9be0 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Wed, 30 Sep 2026 18:13:56 -0500 Subject: [PATCH 63/76] fix web chat interactions and teleport replay --- js/i18n/locales/en-US/common.ftl | 27 ++++ js/i18n/locales/es-ES/common.ftl | 27 ++++ js/i18n/locales/fr-FR/common.ftl | 27 ++++ js/i18n/locales/pt-BR/common.ftl | 27 ++++ js/i18n/locales/ro-RO/common.ftl | 27 ++++ js/i18n/locales/zh-Hans/common.ftl | 24 ++++ js/i18n/locales/zh-Hant/common.ftl | 24 ++++ .../src/components/stream/chat-input.test.tsx | 131 ++++++++++++++++++ js/web/src/components/stream/chat-input.tsx | 5 +- .../stream/teleport-dialog.test.tsx | 97 +++++++++++++ .../src/components/stream/teleport-dialog.tsx | 108 ++++++++++++--- js/web/src/hooks/use-follow.test.tsx | 59 +++++++- js/web/src/hooks/use-follow.ts | 45 ++++-- pkg/api/websocket.go | 18 +++ pkg/model/model.go | 1 + pkg/model/teleport.go | 18 +++ pkg/model/teleport_test.go | 54 ++++++++ 17 files changed, 687 insertions(+), 32 deletions(-) create mode 100644 js/web/src/components/stream/chat-input.test.tsx create mode 100644 js/web/src/components/stream/teleport-dialog.test.tsx create mode 100644 pkg/model/teleport_test.go diff --git a/js/i18n/locales/en-US/common.ftl b/js/i18n/locales/en-US/common.ftl index 1e94f5171..970778937 100644 --- a/js/i18n/locales/en-US/common.ftl +++ b/js/i18n/locales/en-US/common.ftl @@ -283,6 +283,33 @@ theatre-exit = Exit theatre mode teleporting-in = Teleporting in teleporting-to = Teleporting to @{ $handle } +## Teleport dialog +teleport-dialog-title = Teleport to another live streamer +teleport-dialog-description = Select a streamer to teleport your viewers to their stream. +teleport-unknown-streamer = Unknown streamer +teleport-search-label = Search live streams +teleport-search-placeholder = Search by handle or title +teleport-loading-streamers = Loading live streamers… +teleport-empty-streamers = No live streamers found. +teleport-no-matching-streamers = No matching live streamers found. +teleport-viewer-count = { $count -> + [one] { $count } viewer + *[other] { $count } viewers +} +teleport-countdown = Countdown +teleport-seconds-range = seconds (5–300) +teleport-countdown-error = Countdown must be between 5 and 300 seconds. +teleport-selection-expired = That streamer is no longer live. Choose another streamer. +teleport-starting = Starting… +teleport-start = Teleport +teleport-error-streamer-only = Only the streamer of the current livestream can start a teleport. +teleport-error-handle-format = Enter a valid handle, such as handle.bsky.social. +teleport-error-countdown-number = Countdown must be a number of seconds. +teleport-error-self = You cannot teleport to yourself. +teleport-error-resolve-handle = Could not find @{ $handle }. +teleport-error-create = Could not start the teleport. +teleport-error-generic = Could not start the teleport: { $message } + ## Video Card views-count = { $count -> [one] { $count } view diff --git a/js/i18n/locales/es-ES/common.ftl b/js/i18n/locales/es-ES/common.ftl index 8d4b1a5dc..801a0d0a9 100644 --- a/js/i18n/locales/es-ES/common.ftl +++ b/js/i18n/locales/es-ES/common.ftl @@ -80,3 +80,30 @@ login-show-live-on-bluesky-description = Añade el anillo rojo LIVE a tu avatar ## Stream notifications teleporting-in = Teletransportando en teleporting-to = Teletransportando a @{ $handle } + +## Teleport dialog +teleport-dialog-title = Teletransportarse a otra persona en directo +teleport-dialog-description = Selecciona a una persona en directo para enviar a tus espectadores a su stream. +teleport-unknown-streamer = Streamer desconocido +teleport-search-label = Buscar streams en directo +teleport-search-placeholder = Buscar por identificador o título +teleport-loading-streamers = Cargando streamers en directo… +teleport-empty-streamers = No se encontraron streamers en directo. +teleport-no-matching-streamers = No hay streamers en directo que coincidan. +teleport-viewer-count = { $count -> + [one] { $count } espectador + *[other] { $count } espectadores +} +teleport-countdown = Cuenta atrás +teleport-seconds-range = segundos (5–300) +teleport-countdown-error = La cuenta atrás debe estar entre 5 y 300 segundos. +teleport-selection-expired = Ese streamer ya no está en directo. Elige otro. +teleport-starting = Iniciando… +teleport-start = Teletransportar +teleport-error-streamer-only = Solo quien transmite el directo actual puede iniciar una teletransportación. +teleport-error-handle-format = Introduce un identificador válido, como handle.bsky.social. +teleport-error-countdown-number = La cuenta atrás debe ser un número de segundos. +teleport-error-self = No puedes teletransportarte a ti mismo. +teleport-error-resolve-handle = No se encontró a @{ $handle }. +teleport-error-create = No se pudo iniciar la teletransportación. +teleport-error-generic = No se pudo iniciar la teletransportación: { $message } diff --git a/js/i18n/locales/fr-FR/common.ftl b/js/i18n/locales/fr-FR/common.ftl index 9cf84e54d..913c570a2 100644 --- a/js/i18n/locales/fr-FR/common.ftl +++ b/js/i18n/locales/fr-FR/common.ftl @@ -80,3 +80,30 @@ login-show-live-on-bluesky-description = Ajoute l'anneau rouge LIVE à votre ava ## Stream notifications teleporting-in = Téléportation dans teleporting-to = Téléportation vers @{ $handle } + +## Teleport dialog +teleport-dialog-title = Téléporter vers un autre streamer en direct +teleport-dialog-description = Choisissez un streamer vers lequel téléporter vos spectateurs. +teleport-unknown-streamer = Streamer inconnu +teleport-search-label = Rechercher les streams en direct +teleport-search-placeholder = Rechercher par identifiant ou titre +teleport-loading-streamers = Chargement des streamers en direct… +teleport-empty-streamers = Aucun streamer en direct trouvé. +teleport-no-matching-streamers = Aucun streamer en direct ne correspond. +teleport-viewer-count = { $count -> + [one] { $count } spectateur + *[other] { $count } spectateurs +} +teleport-countdown = Compte à rebours +teleport-seconds-range = secondes (5–300) +teleport-countdown-error = Le compte à rebours doit être compris entre 5 et 300 secondes. +teleport-selection-expired = Ce streamer n'est plus en direct. Choisissez-en un autre. +teleport-starting = Démarrage… +teleport-start = Téléporter +teleport-error-streamer-only = Seul le streamer du direct actuel peut lancer une téléportation. +teleport-error-handle-format = Saisissez un identifiant valide, par exemple handle.bsky.social. +teleport-error-countdown-number = Le compte à rebours doit être un nombre de secondes. +teleport-error-self = Vous ne pouvez pas vous téléporter vers vous-même. +teleport-error-resolve-handle = @{ $handle } est introuvable. +teleport-error-create = Impossible de démarrer la téléportation. +teleport-error-generic = Impossible de démarrer la téléportation : { $message } diff --git a/js/i18n/locales/pt-BR/common.ftl b/js/i18n/locales/pt-BR/common.ftl index 96adf49f7..0417e8f12 100644 --- a/js/i18n/locales/pt-BR/common.ftl +++ b/js/i18n/locales/pt-BR/common.ftl @@ -80,3 +80,30 @@ login-show-live-on-bluesky-description = Adiciona o anel vermelho LIVE ao seu av ## Stream notifications teleporting-in = Teletransportando em teleporting-to = Teletransportando para @{ $handle } + +## Teleport dialog +teleport-dialog-title = Teletransportar para outro streamer ao vivo +teleport-dialog-description = Selecione um streamer para enviar seus espectadores ao canal dele. +teleport-unknown-streamer = Streamer desconhecido +teleport-search-label = Buscar transmissões ao vivo +teleport-search-placeholder = Buscar por @ ou título +teleport-loading-streamers = Carregando streamers ao vivo… +teleport-empty-streamers = Nenhum streamer ao vivo encontrado. +teleport-no-matching-streamers = Nenhum streamer ao vivo corresponde à busca. +teleport-viewer-count = { $count -> + [one] { $count } espectador + *[other] { $count } espectadores +} +teleport-countdown = Contagem regressiva +teleport-seconds-range = segundos (5–300) +teleport-countdown-error = A contagem deve ficar entre 5 e 300 segundos. +teleport-selection-expired = Esse streamer não está mais ao vivo. Escolha outro. +teleport-starting = Iniciando… +teleport-start = Teletransportar +teleport-error-streamer-only = Apenas o streamer da transmissão atual pode iniciar um teletransporte. +teleport-error-handle-format = Digite um identificador válido, como handle.bsky.social. +teleport-error-countdown-number = A contagem deve ser um número de segundos. +teleport-error-self = Você não pode se teletransportar para si mesmo. +teleport-error-resolve-handle = Não foi possível encontrar @{ $handle }. +teleport-error-create = Não foi possível iniciar o teletransporte. +teleport-error-generic = Não foi possível iniciar o teletransporte: { $message } diff --git a/js/i18n/locales/ro-RO/common.ftl b/js/i18n/locales/ro-RO/common.ftl index 0187c4b35..843082df4 100644 --- a/js/i18n/locales/ro-RO/common.ftl +++ b/js/i18n/locales/ro-RO/common.ftl @@ -80,3 +80,30 @@ login-show-live-on-bluesky-description = Adaugă inelul roșu LIVE avatarului dv ## Stream notifications teleporting-in = Teleportare în teleporting-to = Teleportare către @{ $handle } + +## Teleport dialog +teleport-dialog-title = Teleportează-te la un alt streamer live +teleport-dialog-description = Alege un streamer către care să-ți teleportezi spectatorii. +teleport-unknown-streamer = Streamer necunoscut +teleport-search-label = Caută transmisiuni live +teleport-search-placeholder = Caută după handle sau titlu +teleport-loading-streamers = Se încarcă streamerii live… +teleport-empty-streamers = Nu s-au găsit streameri live. +teleport-no-matching-streamers = Niciun streamer live nu corespunde căutării. +teleport-viewer-count = { $count -> + [one] { $count } spectator + *[other] { $count } spectatori +} +teleport-countdown = Numărătoare inversă +teleport-seconds-range = secunde (5–300) +teleport-countdown-error = Numărătoarea trebuie să fie între 5 și 300 de secunde. +teleport-selection-expired = Acest streamer nu mai este live. Alege altul. +teleport-starting = Se pornește… +teleport-start = Teleportează +teleport-error-streamer-only = Doar streamerul transmisiunii curente poate porni o teleportare. +teleport-error-handle-format = Introdu un handle valid, de exemplu handle.bsky.social. +teleport-error-countdown-number = Numărătoarea trebuie să fie un număr de secunde. +teleport-error-self = Nu te poți teleporta la tine însuți. +teleport-error-resolve-handle = Nu s-a putut găsi @{ $handle }. +teleport-error-create = Nu s-a putut porni teleportarea. +teleport-error-generic = Nu s-a putut porni teleportarea: { $message } diff --git a/js/i18n/locales/zh-Hans/common.ftl b/js/i18n/locales/zh-Hans/common.ftl index c45a12883..f01b99c8c 100644 --- a/js/i18n/locales/zh-Hans/common.ftl +++ b/js/i18n/locales/zh-Hans/common.ftl @@ -75,3 +75,27 @@ login-show-live-on-bluesky-description = 直播时为您的 Bluesky 头像加上 ## Stream notifications teleporting-in = 传送倒计时 teleporting-to = 正在传送至 @{ $handle } + +## Teleport dialog +teleport-dialog-title = 传送至其他直播主播 +teleport-dialog-description = 选择一位主播,将观众传送到对方的直播间。 +teleport-unknown-streamer = 未知主播 +teleport-search-label = 搜索直播 +teleport-search-placeholder = 按账号或标题搜索 +teleport-loading-streamers = 正在加载直播主播… +teleport-empty-streamers = 未找到直播主播。 +teleport-no-matching-streamers = 没有符合条件的直播主播。 +teleport-viewer-count = { $count } 位观众 +teleport-countdown = 倒计时 +teleport-seconds-range = 秒(5–300) +teleport-countdown-error = 倒计时必须为 5 到 300 秒。 +teleport-selection-expired = 该主播已不在直播。请选择其他主播。 +teleport-starting = 正在启动… +teleport-start = 开始传送 +teleport-error-streamer-only = 只有当前直播的主播可以发起传送。 +teleport-error-handle-format = 请输入有效账号,例如 handle.bsky.social。 +teleport-error-countdown-number = 倒计时必须是秒数。 +teleport-error-self = 无法传送到自己的直播间。 +teleport-error-resolve-handle = 找不到 @{ $handle }。 +teleport-error-create = 无法启动传送。 +teleport-error-generic = 无法启动传送:{ $message } diff --git a/js/i18n/locales/zh-Hant/common.ftl b/js/i18n/locales/zh-Hant/common.ftl index 9ec07cc46..eef4794e5 100644 --- a/js/i18n/locales/zh-Hant/common.ftl +++ b/js/i18n/locales/zh-Hant/common.ftl @@ -80,3 +80,27 @@ login-show-live-on-bluesky-description = 直播時為您的 Bluesky 頭像加上 ## Stream notifications teleporting-in = 傳送倒數 teleporting-to = 正在傳送至 @{ $handle } + +## Teleport dialog +teleport-dialog-title = 傳送至其他直播主 +teleport-dialog-description = 選擇一位直播主,將觀眾傳送到對方的直播間。 +teleport-unknown-streamer = 未知直播主 +teleport-search-label = 搜尋直播 +teleport-search-placeholder = 依帳號或標題搜尋 +teleport-loading-streamers = 正在載入直播主… +teleport-empty-streamers = 找不到直播主。 +teleport-no-matching-streamers = 沒有符合條件的直播主。 +teleport-viewer-count = { $count } 位觀眾 +teleport-countdown = 倒數計時 +teleport-seconds-range = 秒(5–300) +teleport-countdown-error = 倒數時間必須介於 5 到 300 秒。 +teleport-selection-expired = 這位直播主已不在直播。請選擇其他直播主。 +teleport-starting = 正在啟動… +teleport-start = 開始傳送 +teleport-error-streamer-only = 只有目前直播的直播主可以發起傳送。 +teleport-error-handle-format = 請輸入有效帳號,例如 handle.bsky.social。 +teleport-error-countdown-number = 倒數時間必須是秒數。 +teleport-error-self = 無法傳送到自己的直播間。 +teleport-error-resolve-handle = 找不到 @{ $handle }。 +teleport-error-create = 無法啟動傳送。 +teleport-error-generic = 無法啟動傳送:{ $message } diff --git a/js/web/src/components/stream/chat-input.test.tsx b/js/web/src/components/stream/chat-input.test.tsx new file mode 100644 index 000000000..5374e01ff --- /dev/null +++ b/js/web/src/components/stream/chat-input.test.tsx @@ -0,0 +1,131 @@ +import { makeLivestreamStore, registerSlashCommand } from "@streamplace/core"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ChatInput } from "./chat-input"; + +const { mockSend, mockText, mockUseSession, mockClearContent } = vi.hoisted( + () => ({ + mockSend: vi.fn(), + mockText: { current: "" }, + mockUseSession: vi.fn(), + mockClearContent: vi.fn(), + }), +); + +vi.mock("@/lib/session", () => ({ useSession: mockUseSession })); +vi.mock("../../hooks/use-chat-send", () => ({ useChatSend: () => mockSend })); +vi.mock("../../hooks/use-skin-tone", () => ({ + skinToneIndex: () => 0, + useSkinTone: () => [0, vi.fn()], +})); +vi.mock("../../lib/emoji-data", () => ({ + getEmojiData: vi.fn(), + getSkinNative: vi.fn(), + searchEmojis: vi.fn(), + useEmojiData: () => null, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@tiptap/react", () => ({ + EditorContent: () => ( +