Compare commits
1 Commits
main
...
agent/app-
| Author | SHA1 | Date | |
|---|---|---|---|
| c81907ccfe |
@ -52,7 +52,7 @@ Two self-hosted Docker instances, both port 7890:
|
|||||||
| Studio (phase wizard) | — | `studio.js` | mature |
|
| Studio (phase wizard) | — | `studio.js` | mature |
|
||||||
| TTS engines / routing | `routes/tts.py`, `core/routing.py` | `engines.js` | mature |
|
| TTS engines / routing | `routes/tts.py`, `core/routing.py` | `engines.js` | mature |
|
||||||
| STT | `routes/stt.py` | — | mature |
|
| STT | `routes/stt.py` | — | mature |
|
||||||
| **Magazines** | `core/magazine.py`, `routes/magazine.py` | `magazine.js`, `s-magazine.html` | **P0 + P1** |
|
| **Magazines** | `core/magazine.py`, `routes/magazine.py` | `magazine.js`, `s-magazine.html` | **P0 + P1**; generated-MP3 playlist and driving-mode seek/±15 s transport |
|
||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
|
|||||||
@ -258,9 +258,12 @@ works regardless.
|
|||||||
|
|
||||||
`#mag-driving` is a full-screen overlay, not a separate playback path: it
|
`#mag-driving` is a full-screen overlay, not a separate playback path: it
|
||||||
reuses whatever is already playing or queued, so entering it never
|
reuses whatever is already playing or queued, so entering it never
|
||||||
interrupts an article in progress. Two oversized controls (skip, play/pause)
|
interrupts an article in progress. Its touch-sized transport exposes the
|
||||||
and no other interactive surface, matching the "no interaction required"
|
previous/next article, back/forward 15 seconds, play/pause, and direct seeking
|
||||||
behaviour the manual originally specified as a P2 goal.
|
on the shared playlist `<audio>` element. Seeking is bounded to the current
|
||||||
|
track. Playback speed and the optional synchronized text/image companion are
|
||||||
|
browser-local preferences; none of these controls changes server-side article
|
||||||
|
data or creates a second playback state.
|
||||||
|
|
||||||
### Automatic sidebar/advert exclusion
|
### Automatic sidebar/advert exclusion
|
||||||
|
|
||||||
|
|||||||
@ -217,6 +217,46 @@ test.describe('Magazine reader', () => {
|
|||||||
await expect(exportPicker.locator('.vp-item[data-value="smoke_voice"]')).toBeVisible();
|
await expect(exportPicker.locator('.vp-item[data-value="smoke_voice"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('driving mode seeks and skips 15 seconds within the current article', async ({ page }) => {
|
||||||
|
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||||
|
await navTo(page, 's-magazine');
|
||||||
|
await page.locator('#mag-library-list .mag-card').first().click();
|
||||||
|
await page.locator('#mag-driving-open').click();
|
||||||
|
await expect(page.locator('#mag-driving')).toBeVisible();
|
||||||
|
|
||||||
|
// Use a deterministic two-minute track so the transport controls can be
|
||||||
|
// exercised without depending on the browser's media decoder clock.
|
||||||
|
await page.locator('#mag-playlist-audio').evaluate((audio) => {
|
||||||
|
audio.pause();
|
||||||
|
Object.defineProperties(audio, {
|
||||||
|
duration: { configurable: true, value: 120 },
|
||||||
|
currentTime: { configurable: true, writable: true, value: 60 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const currentTime = () => page.locator('#mag-playlist-audio').evaluate((audio) => audio.currentTime);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Back 15 seconds', exact: true }).click();
|
||||||
|
await expect.poll(currentTime).toBe(45);
|
||||||
|
await page.getByRole('button', { name: 'Forward 15 seconds', exact: true }).click();
|
||||||
|
await expect.poll(currentTime).toBe(60);
|
||||||
|
|
||||||
|
await page.locator('#mag-playlist-audio').evaluate((audio) => { audio.currentTime = 5; });
|
||||||
|
await page.getByRole('button', { name: 'Back 15 seconds', exact: true }).click();
|
||||||
|
await expect.poll(currentTime).toBe(0);
|
||||||
|
await page.locator('#mag-playlist-audio').evaluate((audio) => { audio.currentTime = 115; });
|
||||||
|
await page.getByRole('button', { name: 'Forward 15 seconds', exact: true }).click();
|
||||||
|
await expect.poll(currentTime).toBe(120);
|
||||||
|
|
||||||
|
const seek = page.getByRole('slider', { name: 'Position in article' });
|
||||||
|
await seek.evaluate((input) => {
|
||||||
|
input.value = '250';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
await expect.poll(currentTime).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
test('library lists a magazine and opens its contents tree', async ({ page }) => {
|
test('library lists a magazine and opens its contents tree', async ({ page }) => {
|
||||||
await mockBackend(page);
|
await mockBackend(page);
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user