Skip to content

Commit 5dcf8b2

Browse files
committed
feat(media): one sound at a time across route videos, radio and audio tracks
Playing a route video pauses the header radio and the audio tracks, and playing either of those pauses the video (site.mediaExclusivePlayback, default true). A route video with video.muted plays only the picture and stays out of the rule. - shared/lib/media-playback: page-wide playback arbiter (moved from features/audio-player, which re-exports it) - features/video-playback: per-source controllers (YouTube IFrame Player API, Vimeo postMessage protocol, native media, generic iframe focus), embed playback params and useVideoPlayback - content containers: shared header text, card title/description, frame props and container style; md-container renders one branch - route-builders: shared text and frame defaults (output unchanged) - docs: README "Media playback", functionalities pages (en/pt/es), frontend README; e2e/media-playback.spec.ts
1 parent 4bf4d27 commit 5dcf8b2

55 files changed

Lines changed: 1392 additions & 444 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ It does **not** generate `index.html` or `index.js`.
2222
- [Scripts](#scripts)
2323
- [URL Routes and Query Parameters](#url-routes-and-query-parameters)
2424
- [Video routes](#video-routes)
25+
- [Media playback](#media-playback)
2526
- [Authorized Routes](#authorized-routes)
2627
- [CLI Options](#cli-options)
2728
- [AI CLI (interactive docs generator)](#ai-cli-interactive-docs-generator)
@@ -559,6 +560,46 @@ Route definition in `gitpagedocs/docs/versions/0.0.7/config.json`:
559560
}
560561
```
561562

563+
## Media playback
564+
565+
Only one sound plays at a time. With `site.mediaExclusivePlayback` (default `true` in
566+
`gitpagedocs/config.json`):
567+
568+
| Starts playing | Is paused |
569+
| --- | --- |
570+
| a route video | the header radio and the audio tracks |
571+
| the header radio | the route video and the audio tracks |
572+
| an audio track | the route video and the header radio |
573+
574+
How each video source is followed and paused:
575+
576+
| Source (`video.videoType`) | Detects play | Pauses |
577+
| --- | --- | --- |
578+
| `youtube` | official YouTube IFrame Player API (`enablejsapi=1`, state `PLAYING`) | `pauseVideo()` |
579+
| `vimeo` | Vimeo player `postMessage` protocol (`play` event) | `pause` method |
580+
| `mp4`, `webm`, `ogg`, … (native) | the element's `play` event | `pause()` |
581+
| any other embed (`tiktok`, `instagram`, …) | focus moving into the player | reloads the embed |
582+
583+
Customizing:
584+
585+
- **Video with the picture only** — set `"muted": true` in a route's `video` object. The video starts
586+
without sound (YouTube `mute=1`, Vimeo `muted=1`, native `muted`) and stays out of the rule, so it
587+
can run while the radio or an audio track explains it:
588+
589+
```json
590+
{
591+
"id": 5,
592+
"video": {
593+
"videoType": { "en": "youtube" },
594+
"pathVideo": { "en": "r8jQ9hVA2qs" },
595+
"muted": true
596+
}
597+
}
598+
```
599+
600+
- **No rule at all** — `"mediaExclusivePlayback": false` in `site` lets every player run freely (the
601+
radio and the audio tracks still pause each other, as before).
602+
562603
## Authorized Routes
563604

564605
Route authorization is configured per version in:

‎cli/builders/route-builders.mjs‎

Lines changed: 51 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,49 @@
11
/** Build route objects for md, html, and video content types */
22

3+
/** Title/description styling every content route starts from (key order is part of the output). */
4+
const ROUTE_TEXT_DEFAULTS = {
5+
titleCss: "font-size: 1.85rem; font-weight: 700;",
6+
titleDarkCss: "font-size: 1.85rem; font-weight: 700; color: var(--text);",
7+
titleLightCss: "font-size: 1.85rem; font-weight: 700; color: var(--text);",
8+
titlePosition: "center",
9+
titleIsVisible: true,
10+
descriptionCss: "font-size: 1.2rem; font-weight: 500;",
11+
descriptionDarkCss: "font-size: 1.2rem; font-weight: 500; color: var(--text-secondary);",
12+
descriptionLightCss: "font-size: 1.2rem; font-weight: 500; color: var(--text-secondary);",
13+
descriptionPosition: "center",
14+
descriptionIsVisible: true,
15+
};
16+
17+
/** Container frame settings every content route starts from. */
18+
const ROUTE_FRAME_DEFAULTS = {
19+
fullscreenEnabled: true,
20+
marginTop: "",
21+
marginBottom: "",
22+
blockLink: true,
23+
};
24+
25+
/** Each default key, taking the option value when it is set. */
26+
function withDefaults(defaults, options) {
27+
return Object.fromEntries(
28+
Object.entries(defaults).map(([key, value]) => [key, options[key] === undefined ? value : options[key]]),
29+
);
30+
}
31+
32+
/** A single value becomes the same value for every language. */
33+
function perLanguage(value) {
34+
return typeof value === "string" ? { pt: value, en: value, es: value } : value;
35+
}
36+
37+
/** Copies only the fields that are set. */
38+
function assignDefined(target, fields) {
39+
for (const [key, value] of Object.entries(fields)) {
40+
if (value !== undefined) target[key] = value;
41+
}
42+
return target;
43+
}
44+
345
export function buildMdRoute(versionId, routeId, pathByLang, titles, descriptions, options = {}) {
446
const {
5-
titleCss = "font-size: 1.85rem; font-weight: 700;",
6-
titleDarkCss = "font-size: 1.85rem; font-weight: 700; color: var(--text);",
7-
titleLightCss = "font-size: 1.85rem; font-weight: 700; color: var(--text);",
8-
titlePosition = "center",
9-
titleIsVisible = true,
10-
descriptionCss = "font-size: 1.2rem; font-weight: 500;",
11-
descriptionDarkCss = "font-size: 1.2rem; font-weight: 500; color: var(--text-secondary);",
12-
descriptionLightCss = "font-size: 1.2rem; font-weight: 500; color: var(--text-secondary);",
13-
descriptionPosition = "center",
14-
descriptionIsVisible = true,
15-
fullscreenEnabled = true,
16-
marginTop = "",
17-
marginBottom = "",
18-
blockLink = true,
1947
container,
2048
browseAll = false,
2149
RouteguideBrand = true,
@@ -30,33 +58,17 @@ export function buildMdRoute(versionId, routeId, pathByLang, titles, description
3058
id: routeId,
3159
title: titles ?? { pt: "Documentação", en: "Documentation", es: "Documentación" },
3260
description: descriptions ?? { pt: "Descrição da página", en: "Page description", es: "Descripción de la página" },
33-
titleCss,
34-
titleDarkCss,
35-
titleLightCss,
36-
titlePosition,
37-
titleIsVisible,
38-
descriptionCss,
39-
descriptionDarkCss,
40-
descriptionLightCss,
41-
descriptionPosition,
42-
descriptionIsVisible,
61+
...withDefaults(ROUTE_TEXT_DEFAULTS, options),
4362
path: pathByLang,
44-
fullscreenEnabled,
45-
marginTop,
46-
marginBottom,
47-
blockLink,
63+
...withDefaults(ROUTE_FRAME_DEFAULTS, options),
4864
container,
4965
browseAll,
5066
RouteguideBrand,
5167
RouteGuideSpeciFicbrand,
5268
RouteguideBrandPosition,
5369
RouteguideBrandContainerTop,
5470
};
55-
if (container !== undefined) out.container = container;
56-
if (audio !== undefined) out.audio = audio;
57-
if (authorization !== undefined) out.authorization = authorization;
58-
if (hierarchyPage !== undefined) out.hierarchyPage = hierarchyPage;
59-
return out;
71+
return assignDefined(out, { container, audio, authorization, hierarchyPage });
6072
}
6173

6274
export function buildHtmlRoute(versionId, routeId, pathByLang, titles, descriptions, options = {}) {
@@ -65,61 +77,25 @@ export function buildHtmlRoute(versionId, routeId, pathByLang, titles, descripti
6577
}
6678

6779
export function buildVideoRoute(versionId, routeId, videoType, pathVideo, titles, descriptions, options = {}) {
68-
const {
69-
titleCss = "font-size: 1.85rem; font-weight: 700;",
70-
titleDarkCss = "font-size: 1.85rem; font-weight: 700; color: var(--text);",
71-
titleLightCss = "font-size: 1.85rem; font-weight: 700; color: var(--text);",
72-
titlePosition = "center",
73-
titleIsVisible = true,
74-
descriptionCss = "font-size: 1.2rem; font-weight: 500;",
75-
descriptionDarkCss = "font-size: 1.2rem; font-weight: 500; color: var(--text-secondary);",
76-
descriptionLightCss = "font-size: 1.2rem; font-weight: 500; color: var(--text-secondary);",
77-
descriptionPosition = "center",
78-
descriptionIsVisible = true,
79-
fullscreenEnabled = true,
80-
marginTop = "",
81-
marginBottom = "",
82-
blockLink = true,
83-
container,
84-
browseAll = false,
85-
authorization,
86-
} = options;
87-
const videoTypeByLang = typeof videoType === "string" ? { pt: videoType, en: videoType, es: videoType } : videoType;
88-
const pathVideoByLang = typeof pathVideo === "string" ? { pt: pathVideo, en: pathVideo, es: pathVideo } : pathVideo;
80+
const { container, browseAll = false, authorization } = options;
8981
const obj = {
9082
id: routeId,
9183
title: titles ?? { pt: "Vídeo", en: "Video", es: "Vídeo" },
9284
description: descriptions ?? { pt: "Descrição do vídeo", en: "Video description", es: "Descripción del vídeo" },
93-
titleCss,
94-
titleDarkCss,
95-
titleLightCss,
96-
titlePosition,
97-
titleIsVisible,
98-
descriptionCss,
99-
descriptionDarkCss,
100-
descriptionLightCss,
101-
descriptionPosition,
102-
descriptionIsVisible,
103-
fullscreenEnabled,
104-
marginTop,
105-
marginBottom,
106-
blockLink,
85+
...withDefaults(ROUTE_TEXT_DEFAULTS, options),
86+
...withDefaults(ROUTE_FRAME_DEFAULTS, options),
10787
browseAll,
108-
video: { videoType: videoTypeByLang, pathVideo: pathVideoByLang },
88+
video: { videoType: perLanguage(videoType), pathVideo: perLanguage(pathVideo) },
10989
};
110-
if (container !== undefined) obj.container = container;
111-
if (authorization !== undefined) obj.authorization = authorization;
112-
return obj;
90+
return assignDefined(obj, { container, authorization });
11391
}
11492

11593
export function buildAudioRoute(versionId, routeId, audioType, pathAudio, titles, descriptions, options = {}) {
11694
const base = buildVideoRoute(versionId, routeId, audioType, pathAudio, titles, descriptions, options);
117-
const audioTypeByLang = typeof audioType === "string" ? { pt: audioType, en: audioType, es: audioType } : audioType;
118-
const pathAudioByLang = typeof pathAudio === "string" ? { pt: pathAudio, en: pathAudio, es: pathAudio } : pathAudio;
11995
const { video, ...rest } = base;
12096
return {
12197
...rest,
122-
audio: { audioType: audioTypeByLang, pathAudio: pathAudioByLang },
98+
audio: { audioType: perLanguage(audioType), pathAudio: perLanguage(pathAudio) },
12399
};
124100
}
125101

‎cli/content/docs-en.mjs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ The version config can render a **Source code** container through \`routes-sourc
181181
- \`ProjectLink\`, \`layoutsConfigPathOficial\`, \`layoutsConfigPath\`
182182
- Languages: \`site.languages\` (enable/disable each one); UI strings: \`gitpagedocs/langs/<lang>.json\`
183183
184+
## Media playback (one sound at a time)
185+
186+
\`site.mediaExclusivePlayback\` (default \`true\`): playing a route video pauses the radio and the audio tracks, and playing the radio or an audio track pauses the video. It works with YouTube and Vimeo (through their player APIs), native files (\`mp4\`, \`webm\`, …) and any other embed.
187+
188+
To show a video without sound while the radio or an audio track explains it, set \`"muted": true\` in that route's \`video\` object: the video plays only the picture and stays out of the rule. \`"mediaExclusivePlayback": false\` turns the rule off for the whole site.
189+
184190
## Environment variables
185191
186192
- \`GITPAGEDOCS_REPOSITORY_SEARCH\` – repository search (local)

‎cli/content/docs-es.mjs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,12 @@ El config de version puede renderizar un contenedor **Codigo fuente** via \`rout
174174
- \`ProjectLink\`, \`layoutsConfigPathOficial\`, \`layoutsConfigPath\`
175175
- Idiomas: \`site.languages\` (activa/desactiva cada uno); textos de la UI: \`gitpagedocs/langs/<lang>.json\`
176176
177+
## Reproduccion de medios (un sonido a la vez)
178+
179+
\`site.mediaExclusivePlayback\` (por defecto \`true\`): reproducir un video de ruta pausa la radio y las pistas de audio, y reproducir la radio o una pista de audio pausa el video. Funciona con YouTube y Vimeo (mediante las APIs de sus reproductores), archivos nativos (\`mp4\`, \`webm\`, …) y cualquier otro embed.
180+
181+
Para mostrar un video sin sonido mientras la radio o una pista de audio lo explica, usa \`"muted": true\` en el objeto \`video\` de la ruta: el video reproduce solo la imagen y queda fuera de la regla. \`"mediaExclusivePlayback": false\` desactiva la regla en todo el sitio.
182+
177183
## Variables de entorno
178184
179185
- \`GITPAGEDOCS_REPOSITORY_SEARCH\` – busqueda de repositorio (local)

‎cli/content/docs-pt.mjs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,12 @@ O config da versao pode renderizar um container **Codigo fonte** via \`routes-so
174174
- \`ProjectLink\`, \`layoutsConfigPathOficial\`, \`layoutsConfigPath\`
175175
- Idiomas: \`site.languages\` (liga/desliga cada um); textos da UI: \`gitpagedocs/langs/<lang>.json\`
176176
177+
## Reproducao de midia (um som por vez)
178+
179+
\`site.mediaExclusivePlayback\` (padrao \`true\`): dar play num video de rota pausa o radio e as trilhas de audio, e dar play no radio ou numa trilha de audio pausa o video. Funciona com YouTube e Vimeo (pelas APIs dos players), arquivos nativos (\`mp4\`, \`webm\`, …) e qualquer outro embed.
180+
181+
Para mostrar um video sem som enquanto o radio ou uma trilha de audio o explica, use \`"muted": true\` no objeto \`video\` da rota: o video roda so a imagem e fica fora da regra. \`"mediaExclusivePlayback": false\` desliga a regra no site inteiro.
182+
177183
## Variaveis de ambiente
178184
179185
- \`GITPAGEDOCS_REPOSITORY_SEARCH\` – busca de repositorio (local)

‎cli/data/default-site-config.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ export function getDefaultSiteConfig(projectLink, languageToggles = buildLanguag
138138
audioSequentialPlayback: false,
139139
audioPopoverHideSource: false,
140140
audioPopoverShowMinutes: true,
141+
mediaExclusivePlayback: true,
141142
AiChatAutoLockSeconds: 30,
142143
audioTracks: [
143144
{

‎e2e/media-playback.spec.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { test, expect, type FrameLocator, type Locator, type Page } from "@playwright/test";
2+
3+
/**
4+
* `site.mediaExclusivePlayback` (default true): a route video and the header
5+
* "radio" never sound together. The Introduction to Git page shows a YouTube
6+
* route video above its markdown; the radio plays through a YouTube embed, so
7+
* "radio playing" is observable as its mounted iframe and the video state as
8+
* the YouTube player's `playing-mode` / `paused-mode` class. Needs network
9+
* access to youtube.com.
10+
*/
11+
const VIDEO_PAGE_ID = 5;
12+
const YOUTUBE_TIMEOUT_MS = 30_000;
13+
14+
const backgroundMedia = 'iframe[title="Background audio"]';
15+
const routeVideo = '[data-testid="route-video"]';
16+
17+
async function startBackgroundRadio(page: Page): Promise<void> {
18+
await page.locator('[data-testid="audio-player-toggle"]:visible').first().click();
19+
const option = page.getByTestId("audio-track-option").first();
20+
if (await option.isVisible()) await option.click();
21+
}
22+
23+
function youtubePlayer(page: Page): Locator {
24+
const frame: FrameLocator = page.frameLocator(routeVideo);
25+
return frame.locator(".html5-video-player");
26+
}
27+
28+
// Embedded players start from script calls, which headless Chromium blocks without this policy.
29+
test.use({ launchOptions: { args: ["--autoplay-policy=no-user-gesture-required"] } });
30+
31+
test.describe("media playback", () => {
32+
test.beforeEach(async ({ page }) => {
33+
await page.setViewportSize({ width: 1280, height: 900 });
34+
});
35+
36+
test("playing the video pauses the radio, and playing the radio pauses the video", async ({ page }) => {
37+
await page.goto(`/?menu=en&id=${VIDEO_PAGE_ID}`);
38+
// The origin arrives after hydration; then the IFrame API attaches and renames the iframe.
39+
await expect(page.locator(routeVideo)).toHaveAttribute("src", /enablejsapi=1&origin=/);
40+
await expect(page.locator(routeVideo)).not.toHaveAttribute("title", "Video embed", { timeout: YOUTUBE_TIMEOUT_MS });
41+
await expect(youtubePlayer(page)).toBeVisible({ timeout: YOUTUBE_TIMEOUT_MS });
42+
43+
await startBackgroundRadio(page);
44+
await expect(page.locator(backgroundMedia)).toHaveCount(1);
45+
46+
await page.locator(routeVideo).click();
47+
await expect(youtubePlayer(page)).toHaveClass(/playing-mode/, { timeout: YOUTUBE_TIMEOUT_MS });
48+
await expect(page.locator(backgroundMedia)).toHaveCount(0);
49+
50+
await startBackgroundRadio(page);
51+
await expect(page.locator(backgroundMedia)).toHaveCount(1);
52+
await expect(youtubePlayer(page)).toHaveClass(/paused-mode/, { timeout: YOUTUBE_TIMEOUT_MS });
53+
});
54+
});

‎frontend/README.md‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ frontend/
3232
|-- page-slices/ # page compositions: docs-route, introduction-guide, source-viewer
3333
|-- widgets/ # docs-shell, ai-chat-drawer (+ inactivity-lock-dialog), repository-source-browser,
3434
| # search-shell-header, search-shell-layout, not-found-shell
35-
|-- features/ # ask-ai, ai-console, audio-player, docs-access, route-authorization, route-guide,
35+
|-- features/ # ask-ai, ai-console, audio-player, video-playback, docs-access, route-authorization, route-guide,
3636
| # language/theme/version selectors, quick-navigation, repository-search-form,
3737
| # source-viewer-link, source-viewer-search, source-code-highlight, …
3838
|-- entities/ # docs (config/content/embed/layouts/theme), source-viewer
39-
`-- shared/ # ui (dropdown-selector, selection-dialog, confirm-popup, …), lib, config, api
39+
`-- shared/ # ui (dropdown-selector, selection-dialog, confirm-popup, …), lib (media-playback, …), config, api
4040
```
4141

4242
FSD import direction is enforced by the root `eslint.config.mjs` (`app → page-slices → widgets → features → entities → shared`).
@@ -71,6 +71,21 @@ When `site.docsAccess.enabled` is set in `gitpagedocs/config.json` (via the `git
7171
- `GuideHero` enters with Motion variants: rows focus in with a stagger, and the title splits into grapheme glyphs (`model/hero-motion.ts`) that rise in while a visually hidden copy keeps the heading readable as one name. Each row drifts at its own `HERO_PARALLAX.depthPx` as the hero scrolls away.
7272
- `GuideStory` (`ui/story/`) is a scroll-driven tour placed before the full guide. The section is one viewport tall per chapter and a sticky stage plays one chapter per slice of the page's own scroll (wheel, touch, keyboard and scrollbar keep working). Each chapter's numeral, headline and details move at different depths; the keyframes come from `chapterTimeline` in `model/story.ts` and run through function-form `useTransform` with `interpolate`, because the range form hands opacity to the native scroll timeline, which left the first chapter half visible. A rail jumps to any chapter, a skip link goes straight to the full guide, and with reduced motion the tour still crossfades while CSS pins every layer in place.
7373

74+
## Media playback
75+
76+
`shared/lib/media-playback` holds the page-wide playback arbiter: every player registers a `pause`
77+
and calls `claim` before it starts, which pauses all the others. The radio and the audio tracks
78+
(`features/audio-player`) and the route videos (`features/video-playback`) use it, so with
79+
`site.mediaExclusivePlayback` (default `true`) only one of them sounds at a time.
80+
81+
`features/video-playback` picks a controller per source (`resolveVideoProvider`): the official YouTube
82+
IFrame Player API attached to the existing iframe (`enablejsapi=1&origin=…`; it falls back to the
83+
generic rule if the script cannot load), the Vimeo player `postMessage` protocol, the native media
84+
`play` event, or, for any other embed, focus moving into the iframe (paused by reloading it).
85+
`useVideoPlayback` wires the controller to the arbiter and rebuilds it when the source changes; the
86+
page origin comes from `usePageOrigin`, empty in the static render so the exported markup stays
87+
stable. A route video with `video.muted: true` starts without sound and stays out of the rule.
88+
7489
## Environment
7590

7691
`.env` (frontend-local; copy from `.env.example`):
@@ -95,6 +110,7 @@ PORT=3100 pnpm run test:e2e # use an alternate port if 3000 is taken
95110
- `e2e/ai-console.spec.ts` and `e2e/ai-chat-drawer.spec.ts` verify the password gate, encrypted persistence (no plaintext key in `localStorage`), and reload behavior.
96111
- `e2e/docs.spec.ts` checks the docs home renders with no horizontal overflow.
97112
- `e2e/audio-playback.spec.ts` covers the audio route's centered fullscreen view and the mutual exclusion between the route player and the header radio.
113+
- `e2e/media-playback.spec.ts` plays the YouTube route video over the header radio and back (needs network access to youtube.com).
98114
- `e2e/introduction-guide.spec.ts` checks the guide's backdrop, reveal and layout across light, dark and white-primary themes.
99115

100116
## Static export notes

‎frontend/src/entities/docs/model/types/config.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ export interface HierarchyConfig {
4646
export interface VideoRouteConfig {
4747
videoType: Record<LanguageCode, string>;
4848
pathVideo: Record<LanguageCode, string>;
49+
/**
50+
* Plays only the picture (YouTube, Vimeo and native videos start without sound).
51+
* A muted video stays out of `site.mediaExclusivePlayback`, so it can run while
52+
* the radio or an audio track explains it. Default false.
53+
*/
54+
muted?: boolean;
4955
/**
5056
* Optional WebVTT captions per language. When absent the player still renders a
5157
* captions <track> pointing at the empty `captions/empty.vtt` shipped with the site.

0 commit comments

Comments
 (0)