From cc8681bfd50fd8b2cdb504821070088c5488e0e7 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 29 Sep 2026 14:48:25 -0700 Subject: [PATCH 1/2] Title video pages after the video A VOD's browser tab read "Video" (react-navigation's route name) once the app loaded, so open tabs and bookmarks couldn't tell videos apart. - linking: the video page's is the video's own title, falling back to the card title when untitled; og:title is unchanged. - app: the Video screen sets its navigation title from the loaded video. - e2e: the harness publishes a test VOD (exported as VIDEO_URI), and a new web flow checks both the served <title> and the app's document title. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- hack/lib/e2e-harness.sh | 5 +++-- js/app/hooks/useTitle.tsx | 15 +++++++++++++- js/app/src/screens/video.tsx | 30 ++++++++++++++++++++------- js/e2e-web/README.md | 4 ++++ js/e2e-web/flows/08-vod.spec.ts | 36 +++++++++++++++++++++++++++++++++ pkg/cmd/e2e.go | 33 ++++++++++++++++++++++++------ pkg/linking/linking.go | 12 +++++++++-- pkg/linking/linking_test.go | 12 +++++++++-- 8 files changed, 127 insertions(+), 20 deletions(-) create mode 100644 js/e2e-web/flows/08-vod.spec.ts diff --git a/hack/lib/e2e-harness.sh b/hack/lib/e2e-harness.sh index 667a70b6b..42c57a618 100644 --- a/hack/lib/e2e-harness.sh +++ b/hack/lib/e2e-harness.sh @@ -7,7 +7,8 @@ # # The caller's cwd must be the repo root. On return the harness is up and its # variables are exported: SERVER_URL, ACCOUNT_HANDLE, ACCOUNT_DID, -# ACCOUNT_PASSWORD, and in HTTPS mode also SERVER_HTTPS_URL, PDS_HTTPS_URL, +# ACCOUNT_PASSWORD, VIDEO_URI (the account's test VOD), and in HTTPS mode also +# SERVER_HTTPS_URL, PDS_HTTPS_URL, # E2E_PROXY_URL, E2E_TLS_SPKI and E2E_TLS_CA. An EXIT trap stops it; a caller # with its own EXIT work calls e2e_harness_stop from its trap instead. # @@ -77,7 +78,7 @@ e2e_harness_start() { fi # shellcheck disable=SC1090 . "$E2E_ENVFILE" - export SERVER_URL ACCOUNT_HANDLE ACCOUNT_DID ACCOUNT_PASSWORD + export SERVER_URL ACCOUNT_HANDLE ACCOUNT_DID ACCOUNT_PASSWORD VIDEO_URI # only set in HTTPS mode export SERVER_HTTPS_URL PDS_HTTPS_URL E2E_PROXY_URL E2E_TLS_SPKI E2E_TLS_CA echo "harness up: SERVER_URL=$SERVER_URL ACCOUNT_HANDLE=$ACCOUNT_HANDLE${SERVER_HTTPS_URL:+ SERVER_HTTPS_URL=$SERVER_HTTPS_URL}" diff --git a/js/app/hooks/useTitle.tsx b/js/app/hooks/useTitle.tsx index 47cb83a29..f44dae33c 100644 --- a/js/app/hooks/useTitle.tsx +++ b/js/app/hooks/useTitle.tsx @@ -1,5 +1,5 @@ import { useNavigation } from "@react-navigation/native"; -import { useBrandingAsset } from "@streamplace/components"; +import { useBrandingAsset, useVideo } from "@streamplace/components"; import { useEffect } from "react"; export default function useTitle(user: string) { @@ -11,3 +11,16 @@ export default function useTitle(user: string) { }); }, [user, navigation]); } + +// Names a video page after the video, which on web is the document title; +// react-navigation otherwise falls back to the route name ("Video"). Must be +// called inside a VideoProvider. +export function useVideoTitle() { + const navigation = useNavigation(); + const title = useVideo()?.record.title?.trim(); + useEffect(() => { + if (title) { + navigation.setOptions({ title }); + } + }, [title, navigation]); +} diff --git a/js/app/src/screens/video.tsx b/js/app/src/screens/video.tsx index cfedfd9b7..c49ab7327 100644 --- a/js/app/src/screens/video.tsx +++ b/js/app/src/screens/video.tsx @@ -1,6 +1,12 @@ -import { useStreamplaceStore, View, zero } from "@streamplace/components"; +import { + useStreamplaceStore, + VideoProvider, + View, + zero, +} from "@streamplace/components"; import { colors } from "@streamplace/components/src/lib/theme/tokens"; import { Redirect } from "components/aqlink"; +import { useVideoTitle } from "hooks/useTitle"; import { Player } from "../../components/mobile/player"; const BBB_HLS_URL = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"; @@ -10,6 +16,11 @@ function usePlaybackUrl(user: string, tid: string): string { return `at://${user}/place.stream.video/${tid}`; } +function VideoTitle() { + useVideoTitle(); + return null; +} + export default function VideoScreen({ route, }: { @@ -23,12 +34,16 @@ export default function VideoScreen({ return <Redirect to={{ screen: "HomeMain" }} />; } + // The player mounts its own VideoProvider, which defers to this one; this + // one lets the screen read the video too. return ( - <View style={[zero.flex.values[1], { backgroundColor: colors.black }]}> - <View style={{ flex: 1 }}> - <Player src={url} mode="vod" /> - </View> - {/*{src && ( + <VideoProvider aturi={url}> + <VideoTitle /> + <View style={[zero.flex.values[1], { backgroundColor: colors.black }]}> + <View style={{ flex: 1 }}> + <Player src={url} mode="vod" /> + </View> + {/*{src && ( <View style={[ zero.px[4], @@ -41,6 +56,7 @@ export default function VideoScreen({ </Text> </View> )}*/} - </View> + </View> + </VideoProvider> ); } diff --git a/js/e2e-web/README.md b/js/e2e-web/README.md index 36a77ac92..15d1852b9 100644 --- a/js/e2e-web/README.md +++ b/js/e2e-web/README.md @@ -33,6 +33,7 @@ mobile-only. | `04-stream.spec.ts` | `04-stream.yaml` | | `05-oauth-login.spec.ts` | `05-oauth-login.yaml` | | `07-chat-popout.spec.ts` | — (web-only) | +| `08-vod.spec.ts` | — (web-only) | The web app renders the **desktop layout** (a sidebar of nav links), not the mobile tab bar, so a couple of flows adapt to that surface while keeping the @@ -41,6 +42,9 @@ bar, and `03-go-live` reaches the streaming entry point via the Live Dashboard (`/live`), which — logged out — surfaces the same Log In prompt as the mobile "Go Live → Start streaming" path. +`08-vod` opens the VOD the harness publishes and checks the tab is titled +after the video; a document title has no native counterpart. + `04-stream` also covers the narrow portrait web layout at 320 × 568, where the live video sits above chat. It reveals the player chrome, exercises mute and fullscreen entry/exit, and verifies that faded controls reveal instead of diff --git a/js/e2e-web/flows/08-vod.spec.ts b/js/e2e-web/flows/08-vod.spec.ts new file mode 100644 index 000000000..882929673 --- /dev/null +++ b/js/e2e-web/flows/08-vod.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +// The harness publishes a VOD titled "e2e test video" for the test account +// (pkg/cmd/e2e.go) and exports its AT URI as VIDEO_URI. A video page's tab is +// titled after the video, both in the page the node serves and once the app +// has loaded, so open tabs and bookmarks tell videos apart. +const VIDEO_URI = process.env.VIDEO_URI; +const VIDEO_TITLE = "e2e test video"; + +function videoPath(): string { + const match = VIDEO_URI?.match( + /^at:\/\/([^/]+)\/place\.stream\.video\/([^/]+)$/, + ); + if (!match) { + throw new Error(`VIDEO_URI is not a place.stream.video URI: ${VIDEO_URI}`); + } + return `/${match[1]}/video/${match[2]}`; +} + +test("08-vod: the node serves the video page titled after the video", async ({ + request, +}) => { + // The node indexes the record off the firehose, shortly after the harness + // writes it. + await expect + .poll(async () => (await request.get(videoPath())).text()) + .toContain(`<title>${VIDEO_TITLE}`); +}); + +test("08-vod: the app titles the tab after the video", async ({ page }) => { + await page.goto(videoPath()); + // The node's page already carries the title; wait for the app to load the + // video (it shows the title) so the check below sees the app's own title. + await expect(page.getByText(VIDEO_TITLE).first()).toBeVisible(); + await expect(page).toHaveTitle(VIDEO_TITLE); +}); diff --git a/pkg/cmd/e2e.go b/pkg/cmd/e2e.go index f237d8a27..44f575720 100644 --- a/pkg/cmd/e2e.go +++ b/pkg/cmd/e2e.go @@ -38,14 +38,17 @@ import ( // types are used (rather than indigo's) so the record's $type survives // marshalling: indigo's LexiconTypeDecoder only accepts structs with a const // $type field, which glex-generated records do not have. -func createRecord(ctx context.Context, client *xrpc.Client, collection, repo string, record glex.Record) error { +func createRecord(ctx context.Context, client *xrpc.Client, collection, repo string, record glex.Record) (string, error) { inp := spcomatproto.RepoCreateRecord_Input{ Collection: collection, Repo: repo, Record: &glex.LexiconTypeDecoder{Val: record}, } out := spcomatproto.RepoCreateRecord_Output{} - return client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, inp, &out) + if err := client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, inp, &out); err != nil { + return "", err + } + return out.Uri, nil } func makeE2eCommand(build *config.BuildFlags) *urfavecli.Command { @@ -337,7 +340,7 @@ func runE2E(ctx context.Context, devEnvPath, httpsPDSHost, httpsStationHost stri CreatedAt: time.Now().Format(util.ISO8601), CreatedBy: &createdBy, } - if err := createRecord(ctx, xrpcc, "place.stream.key", out.Did, &streamKey); err != nil { + if _, err := createRecord(ctx, xrpcc, "place.stream.key", out.Did, &streamKey); err != nil { return fmt.Errorf("register stream key: %w", err) } // Create a livestream record so the stream shows up in feeds; normally the @@ -351,9 +354,27 @@ func runE2E(ctx context.Context, devEnvPath, httpsPDSHost, httpsStationHost stri LastSeenAt: &now, Title: "e2e test stream", } - if err := createRecord(ctx, xrpcc, "place.stream.livestream", out.Did, &livestream); err != nil { + if _, err := createRecord(ctx, xrpcc, "place.stream.livestream", out.Did, &livestream); err != nil { return fmt.Errorf("create livestream record: %w", err) } + // And a VOD, so flows have a video page to open. It has no source tracks: + // its metadata (title, author) loads, but there is nothing to play. + video := placestream.Video{ + LexiconTypeID: "place.stream.video", + CreatedAt: now, + Title: "e2e test video", + DurationMs: 10_000, + Source: placestream.Video_Source{ + MediaDefs_SourceTracks: &placestream.MediaDefs_SourceTracks{ + LexiconTypeID: "place.stream.media.defs#sourceTracks", + Tracks: []spcomatproto.RepoStrongRef{}, + }, + }, + } + videoURI, err := createRecord(ctx, xrpcc, "place.stream.video", out.Did, &video) + if err != nil { + return fmt.Errorf("create video record: %w", err) + } // Give the node a moment to index the key before we start streaming. time.Sleep(1 * time.Second) @@ -388,8 +409,8 @@ func runE2E(ctx context.Context, devEnvPath, httpsPDSHost, httpsStationHost stri // Print the env vars for the workflow to consume, in one write: callers // poll for SERVER_URL and then read the whole file. - vars := fmt.Sprintf("SERVER_URL=http://%s\nACCOUNT_HANDLE=%s\nACCOUNT_DID=%s\nACCOUNT_PASSWORD=%s\n", - httpAddr, out.Handle, out.Did, password) + vars := fmt.Sprintf("SERVER_URL=http://%s\nACCOUNT_HANDLE=%s\nACCOUNT_DID=%s\nACCOUNT_PASSWORD=%s\nVIDEO_URI=%s\n", + httpAddr, out.Handle, out.Did, password, videoURI) if tlsEnv != nil { // The same node over HTTPS at its public name, plus what clients // need to reach and trust it (see e2e_https.go): a browser pins the diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go index b3d16592b..a7f65f7a8 100644 --- a/pkg/linking/linking.go +++ b/pkg/linking/linking.go @@ -326,7 +326,8 @@ func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *plac } // GenerateVideoCard is the card for a video page: "'s video on " -// (cardVideoTitle) over the video's own title. +// (cardVideoTitle) over the video's own title. The page's is the +// video's own title. func (l *Linker) GenerateVideoCard(ctx context.Context, u *url.URL, vv *placestream.MediaGetVideo_VideoView, sentryDSN string) ([]byte, error) { if u == nil { return nil, errors.New("url is nil") @@ -373,8 +374,15 @@ func (l *Linker) GenerateVideoCard(ctx context.Context, u *url.URL, vv *placestr // authored by the streamer metaTags = append(metaTags, l.atTags(vv.Uri, authorDid)...) + // The tab title is the video's own title, so open tabs and bookmarks + // tell videos apart; the card title stands in for an untitled video. + pageTitle := strings.TrimSpace(video.Title) + if pageTitle == "" { + pageTitle = title + } + return l.GenerateHTML(ctx, &PageConfig{ - Title: title, + Title: pageTitle, Metas: metaTags, SentryDSN: sentryDSN, }) diff --git a/pkg/linking/linking_test.go b/pkg/linking/linking_test.go index c9b779f9a..1c941c149 100644 --- a/pkg/linking/linking_test.go +++ b/pkg/linking/linking_test.go @@ -116,8 +116,10 @@ func TestGenerateVideoCard(t *testing.T) { require.NoError(t, err) linkStr := string(linkCard) require.True(t, strings.Contains(linkStr, "iame.li"), "should contain the author handle") - require.Contains(t, linkStr, "<title>iame.li's video on streamplace node", - "page title names the author (handle without a display name) and the site") + require.Contains(t, linkStr, "My excellent VOD", + "page title is the video's own title") + require.Contains(t, linkStr, ``, + "og:title names the author (handle without a display name) and the site") require.Contains(t, linkStr, ``, "og:description is the video's own title") require.Contains(t, linkStr, ``) @@ -267,6 +269,12 @@ func TestCardTemplates(t *testing.T) { page = string(out) require.Contains(t, page, ``) require.Contains(t, page, ``) + require.Contains(t, page, "Lab replay") + + video.Title = " " + out, err = linker.GenerateVideoCard(ctx, vu, vv, "") + require.NoError(t, err) + require.Contains(t, string(out), "Ada Lovelace on X play", "untitled video: the card title") home, _ := url.Parse("https://example.com/") out, err = linker.GenerateLandingCard(ctx, home, LandingHome, "") -- knotmirror 0.0.1 From 03e8c93d0f5f522024c62e4abc3d4518ffe84079 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 29 Sep 2026 14:58:15 -0700 Subject: [PATCH 2/2] Title untitled videos after their author An untitled video left the tab on react-navigation's "Video"; name it "@handle on " instead, as a stream page is. Co-Authored-By: Claude Opus 5.5 (1M context) --- js/app/hooks/useTitle.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/js/app/hooks/useTitle.tsx b/js/app/hooks/useTitle.tsx index f44dae33c..f1731b43c 100644 --- a/js/app/hooks/useTitle.tsx +++ b/js/app/hooks/useTitle.tsx @@ -14,10 +14,16 @@ export default function useTitle(user: string) { // Names a video page after the video, which on web is the document title; // react-navigation otherwise falls back to the route name ("Video"). Must be -// called inside a VideoProvider. +// called inside a VideoProvider. An untitled video is named after its author, +// as a stream page is. export function useVideoTitle() { const navigation = useNavigation(); - const title = useVideo()?.record.title?.trim(); + const video = useVideo(); + const siteTitle = useBrandingAsset("siteTitle")?.data || "Streamplace"; + const handle = video?.author.handle; + const title = + video?.record.title?.trim() || + (handle ? `@${handle} on ${siteTitle}` : undefined); useEffect(() => { if (title) { navigation.setOptions({ title }); -- knotmirror 0.0.1