/* 字幕文件、中文结果选择与 YouTube 层级选项,使用合成字幕和演示视频。 */ import assert from 'node:assert/strict'; import { before, after, describe, it } from 'node:test'; import { join } from 'node:path'; import type { Browser, Page } from 'playwright-core'; import { launch, requiredEnv, visit as visitPage, VIEWPORTS } from './harness.ts'; import { mockSubtitleMemory } from './subtitle-fixture.ts'; const SRT = '1\n00:00:00,000 --> 00:00:02,000\n测试字幕 粗体\n'; // 每个浏览器用例独立持有服务端字幕夹具,页面刷新沿用同一份记忆。 async function visit(...[browser, path, viewport, prepare]: Parameters) { return visitPage(browser, path, viewport, async page => { await mockSubtitleMemory(page); await prepare?.(page); }); } async function caption(page: Page, text: string) { await page.locator('.vjs-peach-caption-line').filter({ hasText: text }).waitFor({ timeout: 5_000 }).catch(async error => { const state = await page.locator('#stage video.vjs-tech').evaluate((video: HTMLVideoElement) => ({ time: video.currentTime, ready: video.readyState, tracks: [...video.textTracks].map(track => ({ mode: track.mode, cues: track.cues?.length, active: track.activeCues?.length })), overlay: video.closest('.video-js')?.querySelector('.vjs-peach-captions')?.outerHTML, })); throw new Error(`${String(error)} ${JSON.stringify(state)}`); }); } async function menu(page: Page) { await page.locator('#stage [data-player-quality]>button').click(); await page.locator('#stage [data-player-subtitles]').click(); } async function nativeCaptionDisplay(page: Page) { const cdp = await page.context().newCDPSession(page); try { await cdp.send('DOM.enable'); await cdp.send('CSS.enable'); const { root } = await cdp.send('DOM.getDocument', { depth: -1, pierce: true }); const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: '#stage video.vjs-tech' }); type Node = { nodeId: number; attributes?: string[]; children?: Node[]; shadowRoots?: Node[] }; const find = (node: Node, match: (node: Node) => boolean): Node | undefined => { if (match(node)) return node; for (const child of [...(node.children || []), ...(node.shadowRoots || [])]) { const found = find(child, match); if (found) return found; } }; const video = find(root, node => node.nodeId === nodeId)!; const container = find(video, node => !!node.attributes?.includes('-webkit-media-text-track-container'))!; const { computedStyle } = await cdp.send('CSS.getComputedStyleForNode', { nodeId: container.nodeId }); return computedStyle.find(style => style.name === 'display')?.value; } finally { await cdp.detach() } } describe('播放器字幕', () => { let browser: Browser; before(async () => { browser = await launch() }); after(async () => { await browser?.close() }); for (const viewport of VIEWPORTS) { it(`${viewport.name}:面板切换中显示搜索动画,空结果提示保持可见`, { timeout: 60_000 }, async () => { let release!: () => void; const response = new Promise(resolve => { release = resolve }); let searches = 0; const opened = await visit(browser, `/assets/${requiredEnv('PEACH_E2E_ITEM')}`, viewport, async page => { await page.emulateMedia({ reducedMotion: 'no-preference' }); await page.route('**/api/subtitles/search?**', async route => { searches++; await response; await route.fulfill({ json: { results: [], more: false } }); }); }); const { page, problems } = opened; try { await page.locator('#stage .vjs-peach-bezel').waitFor({ state: 'attached' }); await menu(page); await page.locator('[data-subtitle-search]').click(); const panel = page.locator('.vjs-peach-panel:not(.vjs-peach-panel-leaving)'); const status = panel.locator('[data-subtitle-status]'); await status.waitFor({ state: 'visible', timeout: 5_000 }); assert.match(await status.textContent() || '', /正在搜索字幕/); assert.equal(await panel.getAttribute('aria-busy'), 'true'); const spinner = status.locator('.ui-geist-spinner'); assert.equal(await spinner.count(), 1); assert.equal(await spinner.locator('i').first().evaluate(node => node.getAnimations().some(animation => animation.playState === 'running')), true); await page.waitForFunction(() => !document.querySelector('.vjs-peach-popup-animating')); await page.screenshot({ path: join(process.cwd(), `../build/subtitles-search-loading-${viewport.name}.png`) }); release(); await status.filter({ hasText: '没有找到当前作品的中文字幕' }).waitFor(); assert.equal(await spinner.count(), 0); assert.equal(await panel.getAttribute('aria-busy'), null); await page.screenshot({ path: join(process.cwd(), `../build/subtitles-search-empty-${viewport.name}.png`) }); // 字幕面切到搜索面的动画收尾前,退场的那一面(inert,也有返回键)还在菜单里,只点当前这一面里的键。 await panel.locator('[data-subtitle-back]').click(); await panel.locator('[data-subtitle-search]').click(); await status.filter({ hasText: '没有找到当前作品的中文字幕' }).waitFor(); assert.equal(searches, 1); assert.deepEqual(problems, []); } finally { release(); await opened.close() } }); it(`${viewport.name}:本地字幕、选项持久化、关闭和重新开启`, { timeout: 60_000 }, async () => { const opened = await visit(browser, `/assets/${requiredEnv('PEACH_E2E_ITEM')}`, viewport); const { page, problems } = opened; try { await page.locator('#stage .vjs-peach-bezel').waitFor({ state: 'attached' }); assert.equal(await page.locator('#stage .vjs-subtitles-control>button>svg path').count(), 1); await page.waitForFunction(() => document.querySelector('#stage video.vjs-tech')!.readyState >= 2); await page.locator('#stage video.vjs-tech').evaluate((video: HTMLVideoElement) => { video.currentTime = .25 }); await menu(page); const selected = page.locator('[data-subtitle-track][aria-checked="true"]'); const appearance = await selected.evaluate(button => { const svg = button.querySelector('svg')!, label = button.querySelector('.vjs-peach-option-label')!; return { fill: getComputedStyle(svg).fill, stroke: getComputedStyle(svg).stroke, inset: label.getBoundingClientRect().left - button.getBoundingClientRect().left }; }); assert.equal(appearance.fill, 'none'); assert.equal(appearance.stroke, 'rgb(255, 255, 255)'); assert.ok(appearance.inset >= 48, JSON.stringify(appearance)); assert.equal(await page.locator('[data-subtitle-options] use').getAttribute('href'), '#i-settings'); assert.equal(await page.locator('[data-subtitle-options]').getAttribute('aria-label'), '字幕选项'); const cancelled = page.waitForEvent('filechooser'); await page.locator('[data-subtitle-file]').click(); await (await cancelled).setFiles([]); await page.locator('input[type="file"][aria-label="加载字幕文件"]').dispatchEvent('cancel', { bubbles: true }); assert.equal(await page.locator('#stage').evaluate((dialog: HTMLDialogElement) => dialog.open), true); await page.locator('[data-subtitle-file]').focus(); await page.keyboard.press('Escape'); await page.locator('.vjs-peach-settings-menu[aria-hidden="true"]').waitFor({ state: 'attached' }); assert.equal(await page.locator('#stage').evaluate((dialog: HTMLDialogElement) => dialog.open), true); await menu(page); const chooser = page.waitForEvent('filechooser'); await page.locator('[data-subtitle-file]').click(); const subtitleName = '[PREVIEW-096] A long subtitle filename with several wrapped lines decensored-zh-CN.chs.srt'; await (await chooser).setFiles({ name: subtitleName, mimeType: 'text/plain', buffer: Buffer.from(SRT) }); await page.waitForFunction(name => [...document.querySelector('#stage video.vjs-tech')!.textTracks].some(track => track.label === name && track.mode === 'showing'), subtitleName); await caption(page, '测试字幕'); const background = await page.locator('#stage video.vjs-tech').evaluate(() => ({ window: getComputedStyle(document.querySelector('.vjs-peach-caption-window')!).backgroundColor, line: getComputedStyle(document.querySelector('.vjs-peach-caption-line')!).backgroundColor, })); assert.equal(await nativeCaptionDisplay(page), 'none'); assert.equal(background.window, 'rgba(0, 0, 0, 0)'); assert.equal(background.line, 'rgba(0, 0, 0, 0.75)'); const selectedSpacing = await page.locator('[data-subtitle-track][aria-checked="true"]').evaluate(button => { const row = button.getBoundingClientRect(), label = button.querySelector('.vjs-peach-option-label')!.getBoundingClientRect(); return { top: label.top - row.top, bottom: row.bottom - label.bottom, textHeight: label.height }; }); assert.ok(selectedSpacing.textHeight > 30); assert.ok(selectedSpacing.top >= 10 && selectedSpacing.bottom >= 10); assert.ok(Math.abs(selectedSpacing.top - selectedSpacing.bottom) <= 1); assert.equal(await page.locator('#stage .vjs-subtitles-control>button').evaluate(node => getComputedStyle(node, '::after').backgroundColor), await page.locator('#stage .vjs-play-progress').evaluate(node => getComputedStyle(node).backgroundColor)); assert.equal(await page.locator('.vjs-peach-caption-line b').textContent(), '粗体'); await page.screenshot({ path: join(process.cwd(), `../build/subtitles-tracks-${viewport.name}.png`) }); await page.locator('[data-subtitle-options]').click(); await page.locator('[data-subtitle-style="size"]').click(); await page.locator('[data-subtitle-value="150"]').click(); assert.equal(await page.locator('#stage .video-js').evaluate(node => getComputedStyle(node).getPropertyValue('--peach-caption-scale').trim()), '1.5'); await page.locator('[data-subtitle-style="color"]').click(); await page.locator('[data-subtitle-value="yellow"]').click(); assert.equal(await page.locator('.vjs-peach-captions').evaluate(node => getComputedStyle(node).color), 'rgb(255, 255, 0)'); const bounds = await page.locator('#stage .video-js').evaluate(root => { const frame = root.getBoundingClientRect(), menu = root.querySelector('.vjs-peach-settings-menu')!.getBoundingClientRect(); return { menuTop: menu.top, frameTop: frame.top, menuRight: menu.right, frameRight: frame.right }; }); assert.ok(bounds.menuTop >= bounds.frameTop && bounds.menuRight <= bounds.frameRight + 1, JSON.stringify(bounds)); await page.screenshot({ path: join(process.cwd(), `../build/subtitles-${viewport.name}.png`) }); await page.locator('[data-subtitle-back]').click(); await page.getByRole('menuitemradio', { name: '关闭', exact: true }).click(); await page.locator('.vjs-peach-captions').waitFor({ state: 'hidden' }); await page.locator('#stage [data-player-quality]>button').click(); if (viewport.mobile) await page.locator('#stage [data-player-expand]').click(); await page.locator('#stage .vjs-subtitles-control>button').click(); await page.locator('.vjs-peach-captions').waitFor({ state: 'visible' }); await page.reload(); await page.locator('#stage .vjs-peach-bezel').waitFor({ state: 'attached' }); await page.waitForFunction(name => [...document.querySelector('#stage video.vjs-tech')!.textTracks] .some(track => track.label === name && track.mode === 'showing'), subtitleName); assert.equal(await page.locator('#stage .video-js').evaluate(node => getComputedStyle(node).getPropertyValue('--peach-caption-scale').trim()), '1.5'); await menu(page); await page.getByRole('menuitemradio', { name: '关闭', exact: true }).click(); await page.reload(); await page.locator('#stage .vjs-peach-bezel').waitFor({ state: 'attached' }); await page.waitForFunction(name => [...document.querySelector('#stage video.vjs-tech')!.textTracks].some(track => track.label === name), subtitleName); assert.equal(await page.locator('#stage video.vjs-tech').evaluate((video: HTMLVideoElement) => [...video.textTracks].some(track => track.mode === 'showing')), false); await menu(page); await page.locator('[data-subtitle-options]').click(); await page.locator('[data-subtitle-reset]').click(); assert.equal(await page.locator('#stage .video-js').evaluate(node => getComputedStyle(node).getPropertyValue('--peach-caption-scale').trim()), '1'); assert.deepEqual(problems, []); } finally { await opened.close() } }); } it('同目录字幕进入选项,选择和关闭按视频恢复', { timeout: 60_000 }, async () => { const key = 'sidecar:local:stable-file'; const opened = await visit(browser, `/assets/${requiredEnv('PEACH_E2E_ITEM')}`, VIEWPORTS[0]!, async page => { await page.route(/\/api\/assets\/\d+\/subtitles$/, route => route.fulfill({ json: { subtitles: [{ id: key, name: 'STARS-265.srt', label: 'SRT', language: '', playable: true, src: '/local-sidecar.vtt' }], } })); await page.route('**/local-sidecar.vtt', route => route.fulfill({ contentType: 'text/vtt', body: 'WEBVTT\n\n00:00:00.000 --> 00:01:00.000\n同目录字幕\n' })); }); const { page, problems } = opened; try { await page.locator('#stage .vjs-peach-bezel').waitFor({ state: 'attached' }); await page.waitForFunction(id => [...(document.querySelector('#stage video.vjs-tech')?.textTracks || [])].some(track => track.id === id), key); await page.waitForFunction(() => document.querySelector('#stage video.vjs-tech')!.readyState >= 2); await page.locator('#stage video.vjs-tech').evaluate((video: HTMLVideoElement) => { video.currentTime = .25 }); await menu(page); await page.getByRole('menuitemradio', { name: 'SRT', exact: true }).click(); await caption(page, '同目录字幕'); await page.reload(); await page.waitForFunction(id => [...(document.querySelector('#stage video.vjs-tech')?.textTracks || [])].some(track => track.id === id && track.mode === 'showing'), key); await menu(page); await page.getByRole('menuitemradio', { name: '关闭', exact: true }).click(); await page.reload(); await page.waitForFunction(id => [...(document.querySelector('#stage video.vjs-tech')?.textTracks || [])].some(track => track.id === id), key); assert.equal(await page.locator('#stage video.vjs-tech').evaluate((video: HTMLVideoElement) => [...video.textTracks].some(track => track.mode === 'showing')), false); assert.deepEqual(problems, []); } finally { await opened.close() } }); for (const viewport of VIEWPORTS) { it(`${viewport.name}:自动搜索、返回结果与重复加载复用会话`, { timeout: 60_000 }, async () => { let downloads = 0, searches = 0; const opened = await visit(browser, `/assets/${requiredEnv('PEACH_E2E_ITEM')}`, viewport, async page => { await page.route('**/api/item?**', async route => { const response = await route.fetch(), item = await response.json(); return route.fulfill({ response, json: { ...item, code: 'ABP-758' } }); }); await page.route('**/api/subtitles/search?**', route => { searches++; assert.equal(new URL(route.request().url()).searchParams.get('q'), 'ABP-758'); assert.equal(new URL(route.request().url()).searchParams.get('language'), 'zh', '中文界面应搜中文字幕'); return route.fulfill({ json: { results: [{ id: 'test42', title: 'ABP-758 中文字幕', languages: ['zh-Hans'], format: 'SRT' }], more: false } }); }); await page.route('**/api/subtitles/subtitlecat/test42?language=zh', route => route.fulfill({ json: { files: [{ index: 0, name: 'ABP-758.chs.srt', language: 'zh-Hans', playable: true }] } })); await page.route('**/api/subtitles/subtitlecat/test42/0?language=zh', route => { downloads++; return route.fulfill({ contentType: 'text/vtt', body: 'WEBVTT\n\n00:00:00.000 --> 00:00:02.000\n在线字幕\n' }) }); }); const { page, problems } = opened; try { await page.locator('#stage .vjs-peach-bezel').waitFor({ state: 'attached' }); await page.waitForFunction(() => document.querySelector('#stage video.vjs-tech')!.readyState >= 2); await page.locator('#stage video.vjs-tech').evaluate((video: HTMLVideoElement) => { video.currentTime = .25 }); await menu(page); for (let index = 0; index < 2; index++) { await page.locator('[data-subtitle-search]').click(); assert.equal(await page.locator('.vjs-peach-settings-menu').getByRole('searchbox').count(), 0); await page.locator('[data-subtitle-result]').waitFor(); const inset = await page.locator('[data-subtitle-result]').evaluate(button => button.querySelector('span')!.getBoundingClientRect().left - button.getBoundingClientRect().left); assert.ok(inset >= 16, `字幕结果左侧内距 ${inset}px`); await page.screenshot({ path: join(process.cwd(), `../build/subtitles-search-${viewport.name}.png`) }); await page.locator('[data-subtitle-result]').click(); if (index === 0) { await page.locator('[data-subtitle-online]').waitFor(); await page.locator('[data-subtitle-back]').click(); await page.locator('[data-subtitle-result]').click(); } await page.locator('[data-subtitle-online]').click(); // 加载完留在文件页:这一行标「已加载」,状态行报结果,不自己跳回字幕列表。 const files = page.locator('.vjs-peach-panel:not(.vjs-peach-panel-leaving)'); await files.locator('[data-subtitle-online][data-subtitle-loaded]').waitFor(); await files.locator('[data-subtitle-status]', { hasText: '已加载 ABP-758.chs.srt' }).waitFor(); await files.locator('[data-subtitle-back]').click(); await page.locator('.vjs-peach-panel:not(.vjs-peach-panel-leaving) [data-subtitle-back]').click(); await page.getByRole('menuitemradio', { name: 'ABP-758.chs.srt', exact: true }).waitFor(); } await caption(page, '在线字幕'); assert.equal(downloads, 1); assert.equal(searches, 1); assert.equal(await page.locator('#stage video.vjs-tech > track').count(), 1); const chooser = page.waitForEvent('filechooser'); await page.locator('[data-subtitle-file]').click(); await (await chooser).setFiles({ name: '本地字幕.srt', mimeType: 'text/plain', buffer: Buffer.from(SRT) }); await page.getByRole('menuitemradio', { name: '本地字幕.srt', exact: true }).waitFor(); await page.getByRole('menuitemradio', { name: 'ABP-758.chs.srt', exact: true }).click(); await page.reload(); await page.locator('#stage .vjs-peach-bezel').waitFor({ state: 'attached' }); await page.waitForFunction(() => [...document.querySelector('#stage video.vjs-tech')!.textTracks] .some(track => track.label === 'ABP-758.chs.srt' && track.mode === 'showing')); assert.equal(downloads, 1); assert.equal(searches, 1); await menu(page); assert.equal(await page.getByRole('menuitemradio', { name: '本地字幕.srt', exact: true }).count(), 1); assert.equal(await page.locator('[data-subtitle-track][aria-checked="true"]').textContent(), 'ABP-758.chs.srt'); assert.deepEqual(problems, []); } finally { await opened.close() } }); } it('自动搜索失败可重试,空的中文批次可继续查看更多', { timeout: 60_000 }, async () => { const offsets: string[] = []; const opened = await visit(browser, `/assets/${requiredEnv('PEACH_E2E_ITEM')}`, VIEWPORTS[0]!, async page => { await page.route('**/api/subtitles/search?**', route => { offsets.push(new URL(route.request().url()).searchParams.get('offset')!); if (offsets.length === 1) return route.fulfill({ contentType: 'application/json', body: '{' }); return route.fulfill({ json: { results: offsets.length === 2 ? [] : [{ id: 'next', title: '中文字幕', languages: ['zh-Hans'], format: 'SRT' }], more: offsets.length === 2 } }); }); }); try { const { page, problems } = opened; await page.locator('#stage .vjs-peach-bezel').waitFor({ state: 'attached' }); await menu(page); await page.locator('[data-subtitle-search]').click(); await page.locator('[data-subtitle-retry]').click(); await page.locator('[data-subtitle-more]').click(); await page.locator('[data-subtitle-result]').waitFor(); assert.deepEqual(offsets, ['0', '0', '6']); assert.equal(await page.locator('.vjs-peach-settings-menu [aria-busy="true"]').count(), 0); assert.deepEqual(problems, []); } finally { await opened.close() } }); });