feat: lecteur média persistant Now Playing (dock audio, mini-vidéo PiP, Media Session, mobile) #110
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* E2E tests for the ObsiGate audio/video viewers (roadmap #109).
|
||||
* E2E tests for the ObsiGate media viewers & persistent player (roadmap #109/#110).
|
||||
*
|
||||
* Fixtures : `test_vault/sample-audio.mp3` (1 s sine, ~5 ko) +
|
||||
* `test_vault/sample-video.webm` (64x64 VP8, ~1 ko).
|
||||
* Fixtures : `test_vault/sample-audio.mp3` (1 s sine) + `test_vault/sample-video.webm`.
|
||||
*
|
||||
* Run (local):
|
||||
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/media-viewer.spec.js
|
||||
@@ -44,11 +43,10 @@ test.describe('Media viewers — HTML5 audio/video (#109)', () => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-audio.mp3');
|
||||
|
||||
const audio = page.locator('#content-area .audio-viewer-container audio.audio-player');
|
||||
const audio = page.locator('#content-area .audio-viewer-container audio.np-media--audio');
|
||||
await expect(audio).toBeVisible({ timeout: 10000 });
|
||||
await expect(audio).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
|
||||
await expect(audio).toHaveAttribute('controls', '');
|
||||
// La durée est lue côté client via loadedmetadata.
|
||||
await expect(page.locator('#content-area .media-duration-badge')).not.toHaveText('--:--', { timeout: 10000 });
|
||||
});
|
||||
|
||||
@@ -56,7 +54,7 @@ test.describe('Media viewers — HTML5 audio/video (#109)', () => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-video.webm');
|
||||
|
||||
const video = page.locator('#content-area .video-viewer-container video.video-player');
|
||||
const video = page.locator('#content-area .video-viewer-container video.np-media--video');
|
||||
await expect(video).toBeVisible({ timeout: 10000 });
|
||||
await expect(video).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
|
||||
await expect(video).toHaveAttribute('playsinline', '');
|
||||
@@ -73,3 +71,67 @@ test.describe('Media viewers — HTML5 audio/video (#109)', () => {
|
||||
expect(resp.headers()['accept-ranges']).toBe('bytes');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Now Playing — lecture persistante (#110)', () => {
|
||||
|
||||
test('affiche le dock et continue la lecture quand on navigue ailleurs', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-audio.mp3');
|
||||
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Naviguer vers une note : le média doit passer dans le dock.
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
const dock = page.locator('#now-playing-host .np-dock--audio');
|
||||
await expect(dock).toBeVisible({ timeout: 10000 });
|
||||
await expect(dock.locator('.np-dock-title')).toHaveText('sample-audio.mp3');
|
||||
|
||||
// S'assurer de la lecture (l'autoplay peut être bloqué sans geste).
|
||||
const media = page.locator('#now-playing-host audio.np-media--audio');
|
||||
if (await media.evaluate((el) => el.paused)) {
|
||||
await dock.locator('[data-np="play"]').click();
|
||||
}
|
||||
await expect.poll(() => media.evaluate((el) => !el.paused), { timeout: 5000 }).toBe(true);
|
||||
|
||||
// Naviguer encore : toujours en lecture.
|
||||
await openFile(page, 'TestVault', 'Accueil.md');
|
||||
await expect(dock).toBeVisible();
|
||||
expect(await media.evaluate((el) => !el.paused)).toBe(true);
|
||||
});
|
||||
|
||||
test('revient sur le média depuis le dock', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-audio.mp3');
|
||||
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
|
||||
const dock = page.locator('#now-playing-host .np-dock--audio');
|
||||
await expect(dock).toBeVisible({ timeout: 10000 });
|
||||
await dock.locator('[data-np="reopen"]').click();
|
||||
|
||||
await expect(page.locator('#content-area .audio-viewer-container audio.np-media--audio')).toBeVisible({ timeout: 10000 });
|
||||
await expect(dock).toBeHidden();
|
||||
});
|
||||
|
||||
test('ferme la lecture depuis le dock', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-audio.mp3');
|
||||
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
|
||||
const dock = page.locator('#now-playing-host .np-dock--audio');
|
||||
await expect(dock).toBeVisible({ timeout: 10000 });
|
||||
await dock.locator('[data-np="close"]').click();
|
||||
await expect(page.locator('#now-playing-host .np-dock--audio')).toBeHidden();
|
||||
});
|
||||
|
||||
test('le mini-player vidéo flotte et reste visible en naviguant', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-video.webm');
|
||||
await expect(page.locator('#content-area video.np-media--video')).toBeVisible({ timeout: 10000 });
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
|
||||
const mini = page.locator('#now-playing-host .np-dock--video');
|
||||
await expect(mini).toBeVisible({ timeout: 10000 });
|
||||
await expect(mini.locator('video.np-media--video')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Audio/video viewer tests (roadmap #109).
|
||||
* ObsiGate — Media viewers tests (roadmap #109 + #110).
|
||||
*
|
||||
* Pure helpers (formatMediaDuration / buildMediaUrl) are evaluated in a VM
|
||||
* sandbox to avoid importing the full browser-only viewer chain. The rest are
|
||||
* static checks on the source, CSS and backend wiring.
|
||||
* Pure helpers are evaluated in a VM sandbox to avoid importing the
|
||||
* browser-only module chain. The rest are static checks on the source, CSS,
|
||||
* i18n and backend wiring.
|
||||
*
|
||||
* Usage: node tests/frontend/media-viewer.test.mjs
|
||||
*/
|
||||
@@ -19,6 +19,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf8");
|
||||
const np = readFileSync(path.join(ROOT, "frontend", "js", "now-playing.js"), "utf8");
|
||||
const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
|
||||
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
||||
const sw = readFileSync(path.join(ROOT, "frontend", "sw.js"), "utf8");
|
||||
@@ -37,16 +38,18 @@ function test(label, fn) {
|
||||
}
|
||||
|
||||
// ── Pure helpers (VM sandbox) ──────────────────────────────────────────────
|
||||
const begin = viewer.indexOf("export function formatMediaDuration");
|
||||
const end = viewer.indexOf("function buildDownloadUrl");
|
||||
assert.ok(begin !== -1 && end > begin, "media viewer helper block not found");
|
||||
const begin = np.indexOf("const AUDIO_EXTS");
|
||||
const end = np.indexOf("function downloadUrl");
|
||||
assert.ok(begin !== -1 && end > begin, "now-playing helper block not found");
|
||||
const sandbox = {};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
`${viewer.slice(begin, end).replace(/\bexport /g, "")}\nglobalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;`,
|
||||
`${np.slice(begin, end).replace(/\bexport /g, "")}\n` +
|
||||
`globalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;` +
|
||||
`globalThis.isAudioPath = isAudioPath; globalThis.isVideoPath = isVideoPath;`,
|
||||
sandbox,
|
||||
);
|
||||
const { formatMediaDuration, buildMediaUrl } = sandbox;
|
||||
const { formatMediaDuration, buildMediaUrl, isAudioPath, isVideoPath } = sandbox;
|
||||
|
||||
test("formatMediaDuration renders m:ss and h:mm:ss", () => {
|
||||
assert.equal(formatMediaDuration(0), "0:00");
|
||||
@@ -61,49 +64,76 @@ test("buildMediaUrl encodes vault and path", () => {
|
||||
assert.equal(url, "/api/media/My%20Vault?path=caf%C3%A9%20clip.mp4");
|
||||
});
|
||||
|
||||
// ── Static checks: viewer wiring ───────────────────────────────────────────
|
||||
test("isAudioPath / isVideoPath recognise media extensions", () => {
|
||||
for (const p of ["a.mp3", "a.M4A", "dir/b.flac", "c.opus", "d.oga"]) assert.equal(isAudioPath(p), true, p);
|
||||
for (const p of ["a.mp4", "a.WEBM", "dir/b.mov", "c.m4v"]) assert.equal(isVideoPath(p), true, p);
|
||||
for (const p of ["a.md", "a.png", "noext", ""]) {
|
||||
assert.equal(isAudioPath(p), false, p);
|
||||
assert.equal(isVideoPath(p), false, p);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Static checks: viewer delegates to the persistent controller ──────────
|
||||
test("viewer.js dispatches is_audio / is_video to the media viewers", () => {
|
||||
assert.match(viewer, /if \(data\.is_audio\) \{\s*renderAudioViewer\(area, data\);/);
|
||||
assert.match(viewer, /if \(data\.is_video\) \{\s*renderVideoViewer\(area, data\);/);
|
||||
});
|
||||
|
||||
test("audio viewer uses a native <audio> element and stream URL", () => {
|
||||
test("viewer delegates inline rendering to NowPlaying.attachInline", () => {
|
||||
const start = viewer.indexOf("export function renderAudioViewer");
|
||||
const stop = viewer.indexOf("export function renderVideoViewer");
|
||||
const stop = viewer.indexOf("export function renderFile");
|
||||
assert.ok(start !== -1 && stop > start, "renderAudioViewer block not found");
|
||||
const block = viewer.slice(start, stop);
|
||||
assert.match(block, /el\("audio"/);
|
||||
assert.match(block, /controls: ""/);
|
||||
assert.match(block, /data\.stream_url \|\| buildMediaUrl/);
|
||||
assert.match(block, /NowPlaying\.attachInline\(area, data\)/);
|
||||
});
|
||||
|
||||
test("video viewer uses a native <video> element with playsinline", () => {
|
||||
const start = viewer.indexOf("export function renderVideoViewer");
|
||||
const stop = viewer.indexOf("export function renderFile");
|
||||
assert.ok(start !== -1 && stop > start, "renderVideoViewer block not found");
|
||||
const block = viewer.slice(start, stop);
|
||||
assert.match(block, /el\("video"/);
|
||||
assert.match(block, /playsinline: ""/);
|
||||
test("renderFile hands the shared element to the dock before a re-render", () => {
|
||||
assert.match(viewer, /NowPlaying\.handleRender\(area, data\)/);
|
||||
assert.doesNotMatch(viewer, /_mediaViewerCleanup/);
|
||||
});
|
||||
|
||||
test("unsupported codec triggers the binary fallback UI", () => {
|
||||
assert.match(viewer, /audio\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
|
||||
assert.match(viewer, /video\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
|
||||
assert.match(viewer, /export function renderMediaFallback/);
|
||||
// ── Static checks: global Now Playing controller (#110) ───────────────────
|
||||
test("now-playing owns ONE media element reused across surfaces", () => {
|
||||
assert.match(np, /media\.dataset\.npKey === `\$\{data\.vault\}::\$\{data\.path\}`/);
|
||||
assert.match(np, /function ensureMedia\(kind\)/);
|
||||
assert.match(np, /slot\.appendChild\(media\)/);
|
||||
assert.match(np, /dock\.appendChild\(S\.media\)/);
|
||||
});
|
||||
|
||||
test("playback is stopped when the content area is re-rendered", () => {
|
||||
assert.match(viewer, /_mediaViewerCleanup/);
|
||||
assert.match(viewer, /_mediaViewerCleanup\(\); _mediaViewerCleanup = null;/);
|
||||
test("now-playing shows an unsupported fallback on media error", () => {
|
||||
assert.match(np, /media\.addEventListener\('error',\s*\(\)\s*=>\s*onMediaError\(\)\)/);
|
||||
assert.match(np, /export function renderFallback/);
|
||||
});
|
||||
|
||||
test("now-playing wires Media Session, PiP and persistence", () => {
|
||||
assert.match(np, /navigator\.mediaSession\.metadata/);
|
||||
assert.match(np, /setActionHandler\('nexttrack'/);
|
||||
assert.match(np, /requestPictureInPicture/);
|
||||
assert.match(np, /const LS_KEY = 'obsigate-now-playing'/);
|
||||
assert.match(np, /function restore\(\)/);
|
||||
});
|
||||
|
||||
test("now-playing exposes the public controller API and is initialised at boot", () => {
|
||||
assert.match(np, /export const NowPlaying = \{/);
|
||||
assert.match(np, /attachInline/);
|
||||
assert.match(np, /notifyTabClosed/);
|
||||
const app = readFileSync(path.join(ROOT, "frontend", "js", "app.js"), "utf8");
|
||||
assert.match(app, /initNowPlaying\(\)/);
|
||||
});
|
||||
|
||||
// ── Static checks: CSS ─────────────────────────────────────────────────────
|
||||
test("style.css defines the audio/video viewer layout", () => {
|
||||
test("style.css defines the media viewers and the Now Playing dock", () => {
|
||||
assert.match(css, /\.audio-viewer-container/);
|
||||
assert.match(css, /\.audio-stage\s*\{/);
|
||||
assert.match(css, /\.audio-artwork\s*\{/);
|
||||
assert.match(css, /\.video-stage\s*\{/);
|
||||
assert.match(css, /\.video-player\s*\{/);
|
||||
assert.match(css, /\.np-dock--audio\s*\{/);
|
||||
assert.match(css, /\.np-dock--video\s*\{/);
|
||||
assert.match(css, /\.np-expanded\s*\{/);
|
||||
assert.match(css, /body\.np-active \.content-area/);
|
||||
});
|
||||
|
||||
test("style.css no longer references undefined --surface1 / --text-dim vars", () => {
|
||||
assert.doesNotMatch(css, /var\(--surface1\)/);
|
||||
assert.doesNotMatch(css, /var\(--text-dim\)/);
|
||||
});
|
||||
|
||||
// ── Static checks: icons, i18n, service worker ─────────────────────────────
|
||||
@@ -114,8 +144,15 @@ test("utils.js maps media extensions to audio-lines / video icons", () => {
|
||||
assert.match(utils, /"\.webm": "video"/);
|
||||
});
|
||||
|
||||
test("media viewer strings exist in FR and EN", () => {
|
||||
for (const key of ["viewer.media_unsupported", "viewer.media_too_large"]) {
|
||||
test("player/viewer media strings exist in FR and EN", () => {
|
||||
for (const key of [
|
||||
"viewer.media_unsupported",
|
||||
"viewer.media_too_large",
|
||||
"player.now_playing",
|
||||
"player.close",
|
||||
"player.reopen_tab",
|
||||
"player.continues",
|
||||
]) {
|
||||
assert.match(fr, new RegExp(`"${key}"`));
|
||||
assert.match(en, new RegExp(`"${key}"`));
|
||||
}
|
||||
@@ -130,7 +167,6 @@ test("service worker never caches streamed media", () => {
|
||||
test("backend exposes /api/media and the shared Range helper", () => {
|
||||
assert.match(main, /@app\.get\("\/api\/media\/\{vault_name\}"/);
|
||||
assert.match(main, /def _stream_file_with_range\(/);
|
||||
assert.match(main, /AUDIO_EXTENSIONS|is_audio/);
|
||||
assert.match(main, /is_audio\(ext\) or is_video\(ext\)/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user