1 parent
3ef1e47e51
commit
26cc905ca4
16 files changed
+210
-91
No files matched your search
@@ -2,11 +2,11 @@
|
||||
|
||||
Plugin Jellyfin hébergeant un compagnon sur second écran. Sélectionnez votre
|
||||
lecture puis une piste principale texte externe ou embarquée connue de Jellyfin :
|
||||
l'historique montre les cinq derniers blocs commencés à la position estimée.
|
||||
l'historique donne accès à tous les blocs commencés à la position estimée.
|
||||
La piste du lecteur est présélectionnée lorsqu'elle est exploitable. Le choix
|
||||
du compagnon reste indépendant, même si les sous-titres du lecteur sont désactivés.
|
||||
Choisissez aussi une piste d'aide : tous ses blocs partageant une durée positive
|
||||
avec chaque bloc principal sont affichés dessous, sans action de révélation.
|
||||
avec chaque bloc principal sont affichés au-dessus, sans action de révélation.
|
||||
Les frontières adjacentes ne comptent pas ; une absence d'aide est signalée.
|
||||
La langue de la dernière piste d'aide choisie manuellement est conservée dans ce
|
||||
navigateur, sans être synchronisée avec le compte Jellyfin, et réessayée pour
|
||||
@@ -26,7 +26,9 @@ bordée à icône de pause pour l'affichage suspendu. Un tap sur la zone de
|
||||
lecture suspend l'affichage : l'historique affiché est figé pendant que le
|
||||
suivi continue en arrière-plan, et un second tap (ou le bouton « Reprendre
|
||||
l'affichage ») réaffiche immédiatement l'historique à la position suivie, sans
|
||||
reprise automatique ni rattrapage. Le choix de la session, les détails de
|
||||
reprise automatique ni rattrapage. Faire défiler l'historique suspend aussi
|
||||
l'affichage ; une fois suspendu, seul le bouton de reprise revient au direct.
|
||||
Le choix de la session, les détails de
|
||||
lecture et les deux pistes sont repliés derrière le déclencheur « Réglages »,
|
||||
refermable en un geste, sans masquer le statut.
|
||||
Un bouton « Plein écran » demande le plein écran du navigateur, comme un lecteur
|
||||
|
||||
@@ -21,7 +21,7 @@ Décisions de conception établies pendant l'entretien. Elles décrivent le comp
|
||||
## Faits vérifiés dans le code
|
||||
|
||||
- La langue d'aide préférée est conservée dans ce navigateur (clé `compagnon.aide.langue`), relue au chargement, et une piste correspondante est sélectionnée automatiquement pour chaque média ou source ; sans préférence enregistrée, le français est essayé. Un choix manuel remplace la préférence, un choix vide la conserve, et l'absence annoncée depuis le stockage reste distincte d'une erreur de chargement ou d'accès.
|
||||
- Le plein écran occupe déjà la largeur disponible. Les retours à la ligne contenus dans la piste sont conservés par `white-space: pre-line` ; aucun découpage par nombre de mots n'existe.
|
||||
- La piste complète est déjà chargée. La limite de cinq vient de `recentBlocks`, qui ne retient que les cinq derniers blocs commencés pour l'affichage.
|
||||
- Le bloc courant apparaît en haut, suivi des blocs plus anciens. Un clic suspend l'affichage ; le défilement seul ne le suspend pas.
|
||||
- Le plein écran occupe toute la largeur disponible. Le navigateur gère naturellement les retours à la ligne ; aucun découpage par nombre de mots n'existe.
|
||||
- La piste complète est chargée et tous les blocs commencés à la position suivie sont affichés.
|
||||
- Le bloc courant apparaît en haut, suivi des blocs plus anciens. Un clic ou un défilement vers les anciens blocs suspend l'affichage.
|
||||
- L'association de l'aide repose sur le chevauchement temporel avec chaque bloc principal. Un bloc d'aide peut donc être répété pour plusieurs blocs principaux.
|
||||
@@ -54,7 +54,14 @@ test('help uses each historical interval, stays visible and never substitutes ad
|
||||
await expect(entries).toHaveCount(5);
|
||||
await expect(entries.nth(0).locator('.main-block')).toHaveText('Five');
|
||||
await expect(entries.nth(0).locator('.help-block')).toHaveText('<img src=x onerror="window.pwned=1">');
|
||||
assert.deepEqual(await entries.nth(0).evaluate(element => [...element.children].map(child => child.className).filter(Boolean).slice(-2)), ['help-group', 'main-block']);
|
||||
const [helpSize, mainSize] = await Promise.all([
|
||||
entries.nth(0).locator('.help-block').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
entries.nth(0).locator('.main-block').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
]);
|
||||
assert.ok(helpSize >= 32 && mainSize >= 24, 'help and main keep their reading sizes');
|
||||
await expect(entries.nth(1).locator('.help-absent')).toHaveText('Aucune aide associée');
|
||||
assert.ok(await entries.nth(1).locator('.main-block').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize) >= 32), 'a missing help association enlarges the main block');
|
||||
await expect(entries.nth(2).locator('.help-block')).toHaveText(['First', 'Second']);
|
||||
await expect(entries.nth(2).getByRole('group', { name: 'Aide associée' })).toHaveCount(1);
|
||||
await expect(entries.nth(2).locator('.help-absent')).toHaveCount(0);
|
||||
|
||||
+26
-8
@@ -51,10 +51,10 @@ test('history opens mid-film and rebuilds at boundaries, pauses and backward see
|
||||
});
|
||||
await page.goto('http://compagnon.test/jellyfin/Compagnon/');
|
||||
await expect(page.getByLabel('Piste principale')).toHaveValue('4');
|
||||
await expect(page.locator('#history .main-block')).toHaveText(['Bloc 6', 'Bloc 5', 'Bloc 4', 'Bloc 3', 'Bloc 2']);
|
||||
await expect(page.locator('#history .main-block')).toHaveText(['Bloc 6', 'Bloc 5', 'Bloc 4', 'Bloc 3', 'Bloc 2', 'Bloc 1']);
|
||||
await expect(page.locator('#track-status')).toContainText('graphiques');
|
||||
const entries = page.locator('#history > li');
|
||||
await expect(entries).toHaveCount(5);
|
||||
await expect(entries).toHaveCount(6);
|
||||
const current = entries.first();
|
||||
await expect(current).toHaveAttribute('aria-current', 'true');
|
||||
await expect(current.locator('.current-label')).toHaveText('Bloc courant');
|
||||
@@ -65,12 +65,30 @@ test('history opens mid-film and rebuilds at boundaries, pauses and backward see
|
||||
const [historyBox, currentBox] = await Promise.all([page.locator('#history').boundingBox(), current.boundingBox()]);
|
||||
assert.ok(Math.abs(currentBox.y - historyBox.y) < 2, 'the current block starts at the top of the reading zone');
|
||||
const [currentSize, recentSize, olderSize] = await Promise.all([
|
||||
current.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
entries.nth(1).evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
entries.last().evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
current.locator('.main-block').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
entries.nth(1).locator('.main-block').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
entries.last().locator('.main-block').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
]);
|
||||
assert.ok(currentSize > recentSize && recentSize > olderSize, 'emphasis must decrease with age, not only through colour');
|
||||
assert.ok(currentSize >= 24 && recentSize >= 24 && olderSize >= 24, 'older main blocks must keep the reading size');
|
||||
await expect(page.locator('.help-absent')).toHaveCount(0);
|
||||
|
||||
// A scroll away from the current block freezes only the display. Tracking
|
||||
// continues, and an old entry cannot accidentally resume it.
|
||||
await page.setViewportSize({ width: 375, height: 200 });
|
||||
const historyList = page.locator('#history');
|
||||
await expect.poll(() => historyList.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
|
||||
await historyList.evaluate(element => { element.scrollTop = element.scrollHeight; });
|
||||
await expect(page.locator('#status')).toContainText('Affichage suspendu');
|
||||
const frozen = await historyList.textContent();
|
||||
session.PlayState.PositionTicks = 95000000;
|
||||
await page.waitForTimeout(1200);
|
||||
assert.equal(await historyList.textContent(), frozen, 'scroll suspension must keep the displayed snapshot');
|
||||
await historyList.locator('li').last().click();
|
||||
await expect(page.locator('#status')).toContainText('Affichage suspendu');
|
||||
await page.getByRole('button', { name: "Reprendre l'affichage" }).click();
|
||||
await expect(page.locator('#status')).toContainText('Suivi actif');
|
||||
await expect(page.locator('#history .main-block')).toHaveText(['Bloc 7', 'Bloc 6', 'Bloc 5', 'Bloc 4', 'Bloc 3', 'Bloc 2', 'Bloc 1']);
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
session.PlayState.PositionTicks = 40000000;
|
||||
await expect(page.locator('#history .main-block')).toHaveText(['Bloc 5', 'Bloc 4', 'Bloc 3', 'Bloc 2', 'Bloc 1']);
|
||||
session.PlayState.PositionTicks = 39999999;
|
||||
@@ -78,7 +96,7 @@ test('history opens mid-film and rebuilds at boundaries, pauses and backward see
|
||||
session.PlayState.PositionTicks = 0;
|
||||
await expect(page.locator('#history .main-block')).toHaveCount(0);
|
||||
session.PlayState.PositionTicks = 95000000;
|
||||
await expect(page.locator('#history .main-block')).toHaveText(['Bloc 7', 'Bloc 6', 'Bloc 5', 'Bloc 4', 'Bloc 3']);
|
||||
await expect(page.locator('#history .main-block')).toHaveText(['Bloc 7', 'Bloc 6', 'Bloc 5', 'Bloc 4', 'Bloc 3', 'Bloc 2', 'Bloc 1']);
|
||||
const paused = await page.locator('#history').textContent();
|
||||
await page.waitForTimeout(1200);
|
||||
assert.equal(await page.locator('#history').textContent(), paused);
|
||||
@@ -140,7 +158,7 @@ test('history opens mid-film and rebuilds at boundaries, pauses and backward see
|
||||
await expect(page.locator('#history')).toBeEmpty();
|
||||
trackResponse = { TrackEvents: events };
|
||||
await page.getByLabel('Piste principale').selectOption('7');
|
||||
await expect(page.locator('#history .main-block')).toHaveCount(5);
|
||||
await expect(page.locator('#history .main-block')).toHaveCount(7);
|
||||
offline = true;
|
||||
await expect(page.locator('#status')).toContainText('Suivi indisponible');
|
||||
await expect(page.locator('#history')).toBeEmpty();
|
||||
|
||||
@@ -359,6 +359,8 @@ test('ordinary account logs in and sees an explicit absence of playback', async
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByRole('button', { name: 'Plein écran' })).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator('#history')).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByRole('button', { name: 'Se déconnecter' })).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#connect-notice')).toHaveText('Déconnecté.');
|
||||
@@ -418,7 +420,7 @@ test('external subtitles on the installed server follow the real player and enfo
|
||||
|
||||
await tracks.selectOption(String(main.Index));
|
||||
const blocks = companion.locator('#history .main-block');
|
||||
await expect(blocks).toHaveText(['Block six', 'Simultaneous block', 'Block four', 'Block three', 'Block two']);
|
||||
await expect(blocks).toHaveText(['Block six', 'Simultaneous block', 'Block four', 'Block three', 'Block two', 'Block one']);
|
||||
const helpTracks = companion.getByLabel("Piste d'aide", { exact: true });
|
||||
await expect(helpTracks.locator('option')).toHaveText(await tracks.locator('option').allTextContents().then(options => ["Choisir une piste d'aide", ...options.slice(1)]));
|
||||
await helpTracks.selectOption(String(help.Index));
|
||||
@@ -433,12 +435,12 @@ test('external subtitles on the installed server follow the real player and enfo
|
||||
await mkdir('.test-state/issue-4', { recursive: true });
|
||||
await companion.screenshot({ path: '.test-state/issue-4/bilingual.png', fullPage: true });
|
||||
await companion.waitForTimeout(1200);
|
||||
await expect(blocks).toHaveText(['Block six', 'Simultaneous block', 'Block four', 'Block three', 'Block two']);
|
||||
await expect(blocks).toHaveText(['Block six', 'Simultaneous block', 'Block four', 'Block three', 'Block two', 'Block one']);
|
||||
for (const [seconds, expected] of [
|
||||
[19.96, ['Block three', 'Block two', 'Block one']],
|
||||
[20, ['Simultaneous block', 'Block four', 'Block three', 'Block two', 'Block one']],
|
||||
[0, []],
|
||||
[45, ['Block seven', 'Block six', 'Simultaneous block', 'Block four', 'Block three']],
|
||||
[45, ['Block seven', 'Block six', 'Simultaneous block', 'Block four', 'Block three', 'Block two', 'Block one']],
|
||||
]) {
|
||||
await video.evaluate((v, time) => { v.currentTime = time; }, seconds);
|
||||
await expect(blocks).toHaveText(expected, { timeout: 15000 });
|
||||
@@ -455,7 +457,7 @@ test('external subtitles on the installed server follow the real player and enfo
|
||||
assert.equal(streams.some(s => s.Language === 'spa'), false);
|
||||
await helpTracks.selectOption('');
|
||||
await expect(companion.locator('#help-status')).toContainText("Aucune piste d'aide sélectionnée");
|
||||
await expect(blocks).toHaveCount(5);
|
||||
await expect(blocks).toHaveCount(7);
|
||||
await helpTracks.selectOption(String(help.Index));
|
||||
await tracks.selectOption(String(alternative.Index));
|
||||
await expect(blocks).toContainText(['Alternative track']);
|
||||
@@ -653,7 +655,8 @@ test('a media change during a network cut rebuilds the companion from the newly
|
||||
assert.equal(srtTrack.IsExternal, false);
|
||||
await tracks.selectOption(String(srtTrack.Index));
|
||||
const rebuiltBlocks = alice.locator('#history .main-block');
|
||||
await expect(rebuiltBlocks).toHaveCount(5, { timeout: 30000 });
|
||||
await expect(rebuiltBlocks.last()).toContainText('Block one', { timeout: 30000 });
|
||||
assert.ok(await rebuiltBlocks.count() >= 6);
|
||||
assert.ok((await rebuiltBlocks.allTextContents()).some(text => text.includes('Block')));
|
||||
assert.equal(await alice.locator('#history').textContent().then(text => text.includes('Bonjour, café.')), false);
|
||||
} finally { await browser.close(); }
|
||||
@@ -717,7 +720,7 @@ test('embedded SRT, WebVTT and ASS use installed Jellyfin extraction with an ord
|
||||
release();
|
||||
const blocks = companion.locator('#history .main-block');
|
||||
if (format === 'srt') {
|
||||
await expect(blocks).toHaveText(['Block six', 'Simultaneous block', 'Block four', 'Block three', 'Block two']);
|
||||
await expect(blocks).toHaveText(['Block six', 'Simultaneous block', 'Block four', 'Block three', 'Block two', 'Block one']);
|
||||
} else {
|
||||
await expect(blocks).toHaveCount(2);
|
||||
await expect(blocks.last()).toContainText('Bonjour, café.');
|
||||
|
||||
@@ -122,12 +122,14 @@ test('the connection, expiry and disconnect screens stay usable by keyboard at 3
|
||||
await noOverflow();
|
||||
|
||||
// Disconnect screen, keyboard only: Tab reaches the suspend control, the
|
||||
// fullscreen control, then the disconnect button; Enter disconnects.
|
||||
// fullscreen control, the scrollable history, then disconnect; Enter disconnects.
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByRole('button', { name: "Suspendre l'affichage" })).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByRole('button', { name: 'Plein écran' })).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator('#history')).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByRole('button', { name: 'Se déconnecter' })).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#connect-notice')).toHaveText('Déconnecté.');
|
||||
|
||||
@@ -82,7 +82,7 @@ test('the local estimate advances between observations, freezes when stale and r
|
||||
return { json: [] };
|
||||
});
|
||||
await expect(page.locator('#status')).toContainText('Suivi actif');
|
||||
await expect(page.locator('#history .main-block')).toHaveCount(5);
|
||||
await expect(page.locator('#history .main-block')).toHaveCount(6);
|
||||
|
||||
// Between two observations the display keeps moving at 1x instead of
|
||||
// waiting for the server's next whole-second step.
|
||||
|
||||
+25
-11
@@ -21,7 +21,7 @@ const english = { Type: 'Subtitle', Index: 4, Language: 'eng', IsExternal: true,
|
||||
// Serves the built companion and a Jellyfin session. `state.gate` holds the
|
||||
// next session polls so a test can act while a request is in flight.
|
||||
async function mountCompanion(page, { session, streams = [english], tracks = { 4: [] } }) {
|
||||
const state = { gate: null };
|
||||
const state = { gate: null, offline: false };
|
||||
await page.addInitScript(() => localStorage.setItem('compagnon.connexion', JSON.stringify({ token: 'jeton', userId: 'alice', deviceId: 'appareil' })));
|
||||
await page.route(`${origin}/**`, async route => {
|
||||
const url = new URL(route.request().url());
|
||||
@@ -30,6 +30,7 @@ async function mountCompanion(page, { session, streams = [english], tracks = { 4
|
||||
}
|
||||
try {
|
||||
if (url.pathname.endsWith('/Sessions')) {
|
||||
if (state.offline) return await route.abort();
|
||||
if (state.gate) await state.gate;
|
||||
return await route.fulfill({ json: [session] });
|
||||
}
|
||||
@@ -140,7 +141,7 @@ test('the shell keeps tracking visible while settings fold away', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('a tap suspends the display, a tap resumes it at the followed position', async () => {
|
||||
test('a tap suspends the display and the resume action returns to the followed position', async () => {
|
||||
const browser = await chromium.launch();
|
||||
let release;
|
||||
try {
|
||||
@@ -166,8 +167,8 @@ test('a tap suspends the display, a tap resumes it at the followed position', as
|
||||
const status = page.locator('#status');
|
||||
const history = page.locator('#history .main-block');
|
||||
await expect(status).toContainText('Suivi actif');
|
||||
// At 30 s of playback, the five most recent blocks started at 9, 12, 15, 20 and 30 s.
|
||||
await expect(history).toHaveText(['Bloc 10', 'Bloc 9', 'Bloc 8', 'Bloc 7', 'Bloc 6']);
|
||||
// At 30 s of playback, every block started so far is immediately available.
|
||||
await expect(history).toHaveText(['Bloc 10', 'Bloc 9', 'Bloc 8', 'Bloc 7', 'Bloc 6', 'Bloc 5', 'Bloc 4', 'Bloc 3', 'Bloc 2', 'Bloc 1']);
|
||||
await showSettings(page);
|
||||
const position = page.locator('#details dd').nth(4);
|
||||
const followedSeconds = async () => positionSeconds((await position.textContent()) ?? '');
|
||||
@@ -181,6 +182,17 @@ test('a tap suspends the display, a tap resumes it at the followed position', as
|
||||
await expect(page.getByRole('button', { name: "Reprendre l'affichage" })).toBeVisible();
|
||||
const frozenHistory = await page.locator('#history').textContent();
|
||||
|
||||
// A transient tracking failure and recovery keep the requested snapshot;
|
||||
// only a real session/media/source change may clear it.
|
||||
routes.offline = true;
|
||||
await expect(page.getByLabel('Lecture à accompagner')).toBeDisabled();
|
||||
await expect(status).toHaveAttribute('title', /suivi est incertain/i);
|
||||
assert.equal(await page.locator('#history').textContent(), frozenHistory);
|
||||
routes.offline = false;
|
||||
await expect(page.getByLabel('Lecture à accompagner')).toBeEnabled();
|
||||
await expect(status).toContainText('Affichage suspendu');
|
||||
assert.equal(await page.locator('#history').textContent(), frozenHistory);
|
||||
|
||||
// Tracking continues in the background: the followed position advances...
|
||||
playing.PlayState.PositionTicks = 500000000;
|
||||
playing.LastPlaybackCheckIn = new Date().toISOString();
|
||||
@@ -188,7 +200,7 @@ test('a tap suspends the display, a tap resumes it at the followed position', as
|
||||
// ...the history stays frozen even across an explicit track change...
|
||||
await page.getByLabel('Piste principale').selectOption('7');
|
||||
await expect(page.locator('#track-status')).toContainText('Historique à la position estimée');
|
||||
await expect(history).toHaveText(['Bloc 10', 'Bloc 9', 'Bloc 8', 'Bloc 7', 'Bloc 6']);
|
||||
await expect(history).toHaveText(['Bloc 10', 'Bloc 9', 'Bloc 8', 'Bloc 7', 'Bloc 6', 'Bloc 5', 'Bloc 4', 'Bloc 3', 'Bloc 2', 'Bloc 1']);
|
||||
// ...a background resumption check must not lift the freeze...
|
||||
await page.evaluate(() => document.dispatchEvent(new Event('resume')));
|
||||
await page.waitForTimeout(1200);
|
||||
@@ -205,12 +217,12 @@ test('a tap suspends the display, a tap resumes it at the followed position', as
|
||||
await resumed;
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// A second tap resumes immediately at the followed position, now reading
|
||||
// the newly chosen track, with no animated catch-up or step-by-step replay.
|
||||
await page.locator('#reading').click();
|
||||
// The explicit action resumes immediately at the followed position, now
|
||||
// reading the newly chosen track, with no animated catch-up or replay.
|
||||
await page.getByRole('button', { name: "Reprendre l'affichage" }).click();
|
||||
await expect(status).toContainText('Suivi actif');
|
||||
await expect(status).toHaveClass(/status--synced/);
|
||||
await expect(history).toHaveText(['Autre 12', 'Autre 11', 'Autre 10', 'Autre 9', 'Autre 8'], { timeout: 1000 });
|
||||
await expect(history).toHaveText(['Autre 12', 'Autre 11', 'Autre 10', 'Autre 9', 'Autre 8', 'Autre 7', 'Autre 6', 'Autre 5', 'Autre 4', 'Autre 3', 'Autre 2', 'Autre 1'], { timeout: 1000 });
|
||||
await expect.poll(followedSeconds).toBeGreaterThanOrEqual(50);
|
||||
|
||||
// The named control toggles exactly once instead of also firing the zone
|
||||
@@ -270,7 +282,7 @@ test('fullscreen uses the browser fullscreen API with an evident, unpersisted ex
|
||||
// Browser chrome leaves the stage: the main/help hierarchy fills the screen
|
||||
// while the minimal bar stays masked, and the wake lock is best effort.
|
||||
await expect(page.locator('#fullscreen-bar')).toBeHidden();
|
||||
await expect(page.locator('#history .main-block')).toHaveText(['Bloc 10', 'Bloc 9', 'Bloc 8', 'Bloc 7', 'Bloc 6']);
|
||||
await expect(page.locator('#history .main-block')).toHaveText(['Bloc 10', 'Bloc 9', 'Bloc 8', 'Bloc 7', 'Bloc 6', 'Bloc 5', 'Bloc 4', 'Bloc 3', 'Bloc 2', 'Bloc 1']);
|
||||
await expect(page.locator('#history .help-block').first()).toBeVisible();
|
||||
assert.equal(await page.evaluate(() => typeof navigator.wakeLock), 'undefined');
|
||||
const [historyBox, currentBox] = await Promise.all([
|
||||
@@ -464,7 +476,7 @@ test('fullscreen reading scrolls to reach older blocks on a tall page', async ()
|
||||
Text: `Bloc ${index + 1} ${'un texte assez long pour tenir sur plusieurs lignes. '.repeat(4)}`,
|
||||
}));
|
||||
await mountCompanion(page, { session: playing, tracks: { 4: events } });
|
||||
await expect(page.locator('#history .main-block')).toHaveCount(5);
|
||||
await expect(page.locator('#history .main-block')).toHaveCount(10);
|
||||
await page.getByRole('button', { name: 'Plein écran' }).click();
|
||||
await expect(page.locator('main')).toHaveClass(/is-fullscreen/);
|
||||
|
||||
@@ -476,6 +488,8 @@ test('fullscreen reading scrolls to reach older blocks on a tall page', async ()
|
||||
const initial = await current.boundingBox();
|
||||
assert.ok(initial.y >= 0 && initial.y + initial.height <= 814, `the current block must start visible, got y=${initial.y}`);
|
||||
await history.evaluate(element => { element.scrollTop = element.scrollHeight; });
|
||||
await expect(page.locator('#fullscreen-bar')).toBeVisible();
|
||||
await history.evaluate(element => { element.scrollTop = element.scrollHeight; });
|
||||
const oldest = page.locator('#history > li').last();
|
||||
const reached = await oldest.boundingBox();
|
||||
assert.ok(reached.y >= 0 && reached.y + reached.height <= 814, `scrolling must reach the oldest block, got y=${reached.y}`);
|
||||
|
||||
+7
-5
@@ -9,10 +9,12 @@ import {
|
||||
exitFullscreen,
|
||||
fullscreenBarVisible,
|
||||
historyView,
|
||||
historyScroll,
|
||||
logout,
|
||||
retry,
|
||||
selectSession,
|
||||
startCompanion,
|
||||
suspendDisplay,
|
||||
suspended,
|
||||
toggleSettings,
|
||||
toggleSuspension,
|
||||
@@ -119,7 +121,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="reading" aria-label="Zone de lecture" @click="toggleSuspension">
|
||||
<section id="reading" aria-label="Zone de lecture" @click="suspendDisplay">
|
||||
<div id="fullscreen-bar" :hidden="!fullscreenBarVisible" @click.stop>
|
||||
<button id="fullscreen-exit" type="button" @click="exitFullscreen">Quitter le plein écran</button>
|
||||
<button id="fullscreen-resume" type="button" :hidden="!suspended" @click="toggleSuspension">
|
||||
@@ -157,24 +159,24 @@ onMounted(() => {
|
||||
<h3>Une place pour vos sous-titres</h3>
|
||||
<p>{{ ui.trackStatus || 'Lancez une lecture dans Jellyfin, puis choisissez vos pistes dans les réglages.' }}</p>
|
||||
</div>
|
||||
<ol id="history" role="list" aria-label="Cinq derniers blocs">
|
||||
<ol id="history" role="list" tabindex="0" aria-label="Historique complet" @scroll.passive="historyScroll">
|
||||
<li
|
||||
v-for="(entry, index) in historyView"
|
||||
:key="index"
|
||||
:class="entry.emphasis"
|
||||
:class="[entry.emphasis, { 'help-missing': entry.absent }]"
|
||||
:aria-current="entry.emphasis === 'current' ? 'true' : undefined"
|
||||
>
|
||||
<p v-if="entry.emphasis === 'current'" class="current-label" aria-hidden="true">Bloc courant</p>
|
||||
<div class="main-block">{{ entry.main }}</div>
|
||||
<div v-if="entry.helps.length" class="help-group" role="group" aria-label="Aide associée">
|
||||
<div v-for="(help, helpIndex) in entry.helps" :key="helpIndex" class="help-block">{{ help }}</div>
|
||||
</div>
|
||||
<p v-else-if="entry.absent" class="help-absent">Aucune aide associée</p>
|
||||
<div class="main-block">{{ entry.main }}</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
<footer class="reading-footer">
|
||||
<p>Touchez la zone de lecture pour suspendre ou reprendre l’affichage.</p>
|
||||
<p>Touchez la zone de lecture pour suspendre l’affichage. Utilisez « Reprendre l’affichage » pour revenir au direct.</p>
|
||||
<button id="logout" type="button" @click="logout">Se déconnecter</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
+28
-16
@@ -1,18 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { associatedHelp, entryEmphasis, historyEntries, historySignature, recentBlocks } from './history.ts';
|
||||
import { associatedHelp, entryEmphasis, historyBlocks, historyEntries, historySignature } from './history.ts';
|
||||
import type { SubtitleBlock } from './types.ts';
|
||||
|
||||
const block = (start: number, end: number, text: string): SubtitleBlock =>
|
||||
({ StartPositionTicks: start, EndPositionTicks: end, Text: text });
|
||||
|
||||
test('the history keeps the last five blocks started at or before the position', () => {
|
||||
test('the history keeps every block started at or before the position', () => {
|
||||
const blocks = [1, 2, 3, 4, 4, 6, 9].map((second, index) => block(second * 100, second * 100 + 50, `Bloc ${index + 1}`));
|
||||
assert.deepEqual(recentBlocks(blocks, 650).map(b => b.Text), ['Bloc 2', 'Bloc 3', 'Bloc 4', 'Bloc 5', 'Bloc 6']);
|
||||
assert.deepEqual(recentBlocks(blocks, 400).map(b => b.Text), ['Bloc 1', 'Bloc 2', 'Bloc 3', 'Bloc 4', 'Bloc 5']);
|
||||
assert.deepEqual(recentBlocks(blocks, 350).map(b => b.Text), ['Bloc 1', 'Bloc 2', 'Bloc 3']);
|
||||
assert.deepEqual(recentBlocks(blocks, 0), []);
|
||||
assert.deepEqual(recentBlocks(blocks, null), []);
|
||||
assert.deepEqual(historyBlocks(blocks, 650).map(b => b.Text), ['Bloc 1', 'Bloc 2', 'Bloc 3', 'Bloc 4', 'Bloc 5', 'Bloc 6']);
|
||||
assert.deepEqual(historyBlocks(blocks, 400).map(b => b.Text), ['Bloc 1', 'Bloc 2', 'Bloc 3', 'Bloc 4', 'Bloc 5']);
|
||||
assert.deepEqual(historyBlocks(blocks, 350).map(b => b.Text), ['Bloc 1', 'Bloc 2', 'Bloc 3']);
|
||||
assert.deepEqual(historyBlocks(blocks, 0), []);
|
||||
assert.deepEqual(historyBlocks(blocks, null), []);
|
||||
});
|
||||
|
||||
test('help is associated on a positive temporal overlap only', () => {
|
||||
@@ -33,26 +33,26 @@ test('several helps under one main block stack in temporal order', () => {
|
||||
assert.deepEqual(associatedHelp(shuffled, main)?.map(b => b.Text), ['First', 'Second', 'Third']);
|
||||
});
|
||||
|
||||
test('the history signature ignores position moves inside the same recent set', () => {
|
||||
test('the history signature ignores position moves inside the same started history', () => {
|
||||
const blocks = [1, 2, 3].map(second => block(second * 100, second * 100 + 50, `Bloc ${second}`));
|
||||
assert.equal(historySignature(1, 1, blocks, recentBlocks(blocks, 250)), '1:1:2:0');
|
||||
assert.equal(historySignature(1, 1, blocks, recentBlocks(blocks, 260)), '1:1:2:0');
|
||||
assert.equal(historySignature(2, 1, blocks, recentBlocks(blocks, 260)), '2:1:2:0');
|
||||
assert.equal(historySignature(1, 1, blocks, historyBlocks(blocks, 250)), '1:1:2:0');
|
||||
assert.equal(historySignature(1, 1, blocks, historyBlocks(blocks, 260)), '1:1:2:0');
|
||||
assert.equal(historySignature(2, 1, blocks, historyBlocks(blocks, 260)), '2:1:2:0');
|
||||
});
|
||||
|
||||
test('history entries expose help texts and mark a missing association once a help track is loaded', () => {
|
||||
const recent = [block(10, 20, 'One'), block(20, 30, 'Two')];
|
||||
const help = [block(15, 25, 'Shared'), block(22, 26, 'Second')];
|
||||
assert.deepEqual(historyEntries(recent, help), [
|
||||
const history = [block(10, 20, 'One'), block(20, 30, 'Two')];
|
||||
const help = [block(15, 25, 'Shared'), block(22, 26, 'Second'), block(25, 25, 'Zero duration')];
|
||||
assert.deepEqual(historyEntries(history, help), [
|
||||
{ main: 'Two', helps: ['Shared', 'Second'], absent: false, emphasis: 'current' },
|
||||
{ main: 'One', helps: ['Shared'], absent: false, emphasis: 'recent' },
|
||||
]);
|
||||
assert.deepEqual(historyEntries(recent, [block(0, 10, 'Before')]), [
|
||||
assert.deepEqual(historyEntries(history, [block(0, 10, 'Before')]), [
|
||||
{ main: 'Two', helps: [], absent: true, emphasis: 'current' },
|
||||
{ main: 'One', helps: [], absent: true, emphasis: 'recent' },
|
||||
]);
|
||||
// Without a loaded help track, the reading zone does not claim an absence.
|
||||
assert.deepEqual(historyEntries(recent, null), [
|
||||
assert.deepEqual(historyEntries(history, null), [
|
||||
{ main: 'Two', helps: [], absent: false, emphasis: 'current' },
|
||||
{ main: 'One', helps: [], absent: false, emphasis: 'recent' },
|
||||
]);
|
||||
@@ -66,3 +66,15 @@ test('the current block is rendered first and older blocks lose emphasis', () =>
|
||||
assert.equal(entryEmphasis(1, 5), 'older');
|
||||
assert.equal(entryEmphasis(0, 5), 'older');
|
||||
});
|
||||
|
||||
test('a long history associates sorted tracks without scanning the help track per entry', () => {
|
||||
const count = 20_000;
|
||||
const main = Array.from({ length: count }, (_, index) => block(index * 10, index * 10 + 10, `Main ${index}`));
|
||||
const help = Array.from({ length: count }, (_, index) => block(index * 10, index * 10 + 10, `Help ${index}`));
|
||||
const started = performance.now();
|
||||
const entries = historyEntries(main, help);
|
||||
|
||||
assert.ok(performance.now() - started < 1_000, 'long tracks should be associated without a quadratic rebuild');
|
||||
assert.deepEqual(entries[0], { main: 'Main 19999', helps: ['Help 19999'], absent: false, emphasis: 'current' });
|
||||
assert.deepEqual(entries.at(-1), { main: 'Main 0', helps: ['Help 0'], absent: false, emphasis: 'older' });
|
||||
});
|
||||
+27
-12
@@ -9,18 +9,20 @@ export interface HistoryEntry {
|
||||
|
||||
export type HistoryEmphasis = 'current' | 'recent' | 'older';
|
||||
|
||||
export function recentBlocks(blocks: SubtitleBlock[], ticks: number | null): SubtitleBlock[] {
|
||||
const overlaps = (first: SubtitleBlock, second: SubtitleBlock) =>
|
||||
Math.max(first.StartPositionTicks, second.StartPositionTicks) < Math.min(first.EndPositionTicks, second.EndPositionTicks);
|
||||
|
||||
export function historyBlocks(blocks: SubtitleBlock[], ticks: number | null): SubtitleBlock[] {
|
||||
if (typeof ticks !== 'number') return [];
|
||||
return blocks.filter(block => block.StartPositionTicks <= ticks).slice(-5);
|
||||
return blocks.filter(block => block.StartPositionTicks <= ticks);
|
||||
}
|
||||
|
||||
export function associatedHelp(
|
||||
helpBlocks: SubtitleBlock[] | null,
|
||||
block: SubtitleBlock,
|
||||
): SubtitleBlock[] | null {
|
||||
return helpBlocks?.filter(help =>
|
||||
Math.max(help.StartPositionTicks, block.StartPositionTicks) < Math.min(help.EndPositionTicks, block.EndPositionTicks),
|
||||
).sort((first, second) => first.StartPositionTicks - second.StartPositionTicks) ?? null;
|
||||
return helpBlocks?.filter(help => overlaps(help, block))
|
||||
.sort((first, second) => first.StartPositionTicks - second.StartPositionTicks) ?? null;
|
||||
}
|
||||
|
||||
export function entryEmphasis(index: number, total: number): HistoryEmphasis {
|
||||
@@ -31,16 +33,29 @@ export function entryEmphasis(index: number, total: number): HistoryEmphasis {
|
||||
}
|
||||
|
||||
export function historyEntries(
|
||||
recent: SubtitleBlock[],
|
||||
history: SubtitleBlock[],
|
||||
helpBlocks: SubtitleBlock[] | null,
|
||||
): HistoryEntry[] {
|
||||
return recent.map((block, index) => {
|
||||
const helps = associatedHelp(helpBlocks, block)?.map(help => help.Text) ?? [];
|
||||
const sortedHelp = helpBlocks === null
|
||||
? null
|
||||
: [...helpBlocks].sort((first, second) => first.StartPositionTicks - second.StartPositionTicks);
|
||||
let nextHelp = 0;
|
||||
let activeHelp: SubtitleBlock[] = [];
|
||||
|
||||
return history.map((block, index) => {
|
||||
activeHelp = activeHelp.filter(help => help.EndPositionTicks > block.StartPositionTicks);
|
||||
while (sortedHelp && nextHelp < sortedHelp.length && sortedHelp[nextHelp].StartPositionTicks < block.EndPositionTicks) {
|
||||
const help = sortedHelp[nextHelp++];
|
||||
if (help.EndPositionTicks > block.StartPositionTicks) activeHelp.push(help);
|
||||
}
|
||||
const helps = sortedHelp === null
|
||||
? []
|
||||
: activeHelp.filter(help => overlaps(help, block)).map(help => help.Text);
|
||||
return {
|
||||
main: block.Text,
|
||||
helps,
|
||||
absent: helpBlocks !== null && helps.length === 0,
|
||||
emphasis: entryEmphasis(index, recent.length),
|
||||
absent: sortedHelp !== null && helps.length === 0,
|
||||
emphasis: entryEmphasis(index, history.length),
|
||||
};
|
||||
}).reverse();
|
||||
}
|
||||
@@ -49,7 +64,7 @@ export function historySignature(
|
||||
trackGeneration: number,
|
||||
helpGeneration: number,
|
||||
blocks: SubtitleBlock[],
|
||||
recent: SubtitleBlock[],
|
||||
history: SubtitleBlock[],
|
||||
): string {
|
||||
return `${trackGeneration}:${helpGeneration}:${recent.length}:${recent.length ? blocks.indexOf(recent[0]) : -1}`;
|
||||
return `${trackGeneration}:${helpGeneration}:${history.length}:${history.length ? blocks.indexOf(history[0]) : -1}`;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ test('a user-suspended display is its own explicit state with a resume hint', ()
|
||||
assert.equal(status.kind, 'suspended');
|
||||
assert.equal(status.message, 'Affichage suspendu');
|
||||
assert.match(status.hint, /suivi continue en arrière-plan/);
|
||||
assert.match(status.hint, /zone de lecture/);
|
||||
assert.match(status.hint, /bouton de reprise/);
|
||||
assert.match(status.hint, /position suivie/);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ test('a suspended display reports stale tracking in its hint without changing st
|
||||
const status = trackingStatus({ fresh: false, observationAgeMs: 30000, resyncPending: false, suspended: true });
|
||||
assert.equal(status.kind, 'suspended', 'a requested freeze must stay distinct from a suffered one');
|
||||
assert.match(status.hint, /suivi est incertain/);
|
||||
assert.match(status.hint, /zone de lecture/);
|
||||
assert.match(status.hint, /bouton de reprise/);
|
||||
});
|
||||
|
||||
test('suspension outranks the resumption check', () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ export function trackingStatus(options: {
|
||||
return {
|
||||
kind: 'suspended',
|
||||
message: 'Affichage suspendu',
|
||||
hint: `${tracking} Touchez la zone de lecture pour reprendre à la position suivie.`,
|
||||
hint: `${tracking} Utilisez le bouton de reprise pour revenir à la position suivie.`,
|
||||
};
|
||||
}
|
||||
if (options.resyncPending) {
|
||||
|
||||
+34
-9
@@ -3,7 +3,7 @@ import { ApiError, createApi } from './api.ts';
|
||||
import { parseSavedConnection } from './lib/connection.ts';
|
||||
import { fullscreenBarVisible as showFullscreenBar } from './lib/fullscreen.ts';
|
||||
import { defaultHelpLanguage, helpAbsenceNotice, helpTrackForLanguage, languageKey, parseHelpLanguage } from './lib/helpLanguage.ts';
|
||||
import { historyEntries, historySignature, recentBlocks } from './lib/history.ts';
|
||||
import { historyBlocks, historyEntries, historySignature } from './lib/history.ts';
|
||||
import type { HistoryEntry } from './lib/history.ts';
|
||||
import { quickConnectView } from './lib/quickConnect.ts';
|
||||
import type { QuickConnectPhase } from './lib/quickConnect.ts';
|
||||
@@ -90,6 +90,7 @@ let quickGeneration = 0;
|
||||
let quickTimer = 0;
|
||||
let sessions: Session[] = [];
|
||||
let context = '';
|
||||
let frozenContext = '';
|
||||
let trackGeneration = 0;
|
||||
let trackNotice = '';
|
||||
let blocks: SubtitleBlock[] | null = null;
|
||||
@@ -120,14 +121,26 @@ function focusRetry() {
|
||||
}
|
||||
|
||||
function setMessage(message: string, kind: StatusKind = 'info') {
|
||||
// A requested suspension owns the status bar: a background outage or a
|
||||
// vanished session must not replace the freeze the user asked for.
|
||||
// A requested suspension owns the status bar: a background outage must not
|
||||
// replace the freeze the user asked for.
|
||||
if (suspended.value) return;
|
||||
ui.status = { kind, message, hint: '' };
|
||||
}
|
||||
|
||||
function resumeDisplay() {
|
||||
ui.frozenHistory = null;
|
||||
frozenContext = '';
|
||||
void nextTick(() => {
|
||||
const history = document.getElementById('history');
|
||||
if (history) history.scrollTop = 0;
|
||||
});
|
||||
}
|
||||
|
||||
function freezeDisplay() {
|
||||
if (suspended.value) return;
|
||||
ui.frozenHistory = ui.history;
|
||||
frozenContext = context;
|
||||
renderStatus(currentSession());
|
||||
}
|
||||
|
||||
function codecOf(options: StreamOption[], value: string) {
|
||||
@@ -161,8 +174,8 @@ function resetSubtitles() {
|
||||
function renderHistory(estimate: number | null = ui.displayTicks) {
|
||||
if (blocks === null) return;
|
||||
const ticks = estimate ?? ui.displayTicks;
|
||||
const recent = recentBlocks(blocks, ticks);
|
||||
const signature = historySignature(trackGeneration, helpGeneration, blocks, recent);
|
||||
const history = historyBlocks(blocks, ticks);
|
||||
const signature = historySignature(trackGeneration, helpGeneration, blocks, history);
|
||||
if (historySignatureValue === signature) return;
|
||||
historySignatureValue = signature;
|
||||
if (typeof ticks !== 'number') {
|
||||
@@ -170,8 +183,8 @@ function renderHistory(estimate: number | null = ui.displayTicks) {
|
||||
ui.trackStatus = `Position indisponible. ${trackNotice}`;
|
||||
return;
|
||||
}
|
||||
ui.history = historyEntries(recent, helpBlocks);
|
||||
ui.trackStatus = `${recent.length ? 'Historique à la position estimée.' : 'Aucun bloc commencé à cette position.'} ${trackNotice}`;
|
||||
ui.history = historyEntries(history, helpBlocks);
|
||||
ui.trackStatus = `${history.length ? 'Historique à la position estimée.' : 'Aucun bloc commencé à cette position.'} ${trackNotice}`;
|
||||
}
|
||||
|
||||
async function readTrack(session: Session, value: string, codec: string): Promise<SubtitleBlock[]> {
|
||||
@@ -227,6 +240,7 @@ async function updateSubtitles(session: Session | undefined) {
|
||||
const item = session?.NowPlayingItem?.Id;
|
||||
const source = session?.PlayState?.MediaSourceId;
|
||||
if (!session || !item || !source) {
|
||||
resumeDisplay();
|
||||
resetSubtitles();
|
||||
if (session) ui.trackStatus = 'Source réellement lue non communiquée : pistes indisponibles.';
|
||||
return;
|
||||
@@ -236,6 +250,7 @@ async function updateSubtitles(session: Session | undefined) {
|
||||
renderHistory();
|
||||
return;
|
||||
}
|
||||
if (!suspended.value || frozenContext !== next) resumeDisplay();
|
||||
resetSubtitles();
|
||||
context = next;
|
||||
// A manual "no help" choice belongs to its own media, source and session:
|
||||
@@ -357,6 +372,7 @@ async function poll() {
|
||||
sessions = [];
|
||||
resetSubtitles();
|
||||
ui.details = [];
|
||||
renderStatus(undefined);
|
||||
setMessage(`Suivi indisponible. ${error instanceof Error ? error.message : 'Réessayez.'}`, 'uncertain');
|
||||
}
|
||||
if (current === generation && token) timer = window.setTimeout(poll, 1000);
|
||||
@@ -532,6 +548,16 @@ export function toggleSettings() {
|
||||
ui.settingsOpen = !ui.settingsOpen;
|
||||
}
|
||||
|
||||
export function historyScroll(event: Event) {
|
||||
const history = event.currentTarget as HTMLElement | null;
|
||||
if (!history || history.scrollTop <= 0) return;
|
||||
freezeDisplay();
|
||||
}
|
||||
|
||||
export function suspendDisplay() {
|
||||
freezeDisplay();
|
||||
}
|
||||
|
||||
// The fullscreen mode is kept in memory only: it is never saved, so every visit
|
||||
// starts in the ordinary shell. The browser API is best effort; the CSS overlay
|
||||
// remains usable when the request is denied. Leaving it releases the wake lock
|
||||
@@ -583,8 +609,7 @@ export function toggleSuspension() {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
ui.frozenHistory = ui.history;
|
||||
renderStatus(currentSession());
|
||||
freezeDisplay();
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
|
||||
+16
-7
@@ -23,8 +23,8 @@
|
||||
--font-size-section: 1.25rem;
|
||||
--font-size-code: 2.5rem;
|
||||
--font-size-current: 1.5rem;
|
||||
--font-size-older: 1rem;
|
||||
--font-size-help: 1rem;
|
||||
--font-size-older: 1.5rem;
|
||||
--font-size-help: 2rem;
|
||||
--font-size-label: .8125rem;
|
||||
--font-size-status: .875rem;
|
||||
--line-height-reading: 1.7;
|
||||
@@ -304,6 +304,8 @@
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-md);
|
||||
max-height: min(70vh, 48rem);
|
||||
overflow-y: auto;
|
||||
min-height: var(--reading-zone-min-height);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -311,11 +313,12 @@
|
||||
}
|
||||
#history:empty { min-height: 0; }
|
||||
#history li {
|
||||
white-space: pre-line;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#history .older, #history .recent { padding: var(--space-xs) var(--space-lg); border-inline-start: var(--reading-accent-width) solid var(--color-border); }
|
||||
#history .older { font-size: var(--font-size-older); color: var(--color-text-soft); }
|
||||
#history .older, #history .recent { padding: var(--space-xs) var(--space-lg); border-inline-start: var(--reading-accent-width) solid var(--color-border); font-size: var(--font-size-older); color: var(--color-text-soft); }
|
||||
#history .current {
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-surface);
|
||||
@@ -324,6 +327,8 @@
|
||||
font-size: var(--font-size-current);
|
||||
font-weight: var(--font-weight-strong);
|
||||
}
|
||||
.main-block { font-size: var(--font-size-current); }
|
||||
.help-missing .main-block { font-size: var(--font-size-help); }
|
||||
.current-label {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-label);
|
||||
@@ -344,7 +349,7 @@
|
||||
.help-block:first-child { margin-block-start: 0; }
|
||||
.help-absent {
|
||||
margin: var(--space-sm) 0 0;
|
||||
font-size: var(--font-size-help);
|
||||
font-size: var(--font-size-current);
|
||||
font-weight: normal;
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
@@ -364,9 +369,11 @@
|
||||
main.is-fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
background: var(--color-oled);
|
||||
}
|
||||
main.is-fullscreen #bar,
|
||||
@@ -382,6 +389,7 @@
|
||||
main.is-fullscreen #reading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: var(--space-md);
|
||||
@@ -391,6 +399,7 @@
|
||||
main.is-fullscreen #history {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
+16
-6
@@ -103,15 +103,23 @@ test('the current block dominates without relying on colour alone', () => {
|
||||
assert.ok(contrast(resolve(declaration(label, 'color')), resolve('var(--color-surface)')) >= 4.5, 'the current marker must reach AA on the block');
|
||||
});
|
||||
|
||||
test('older blocks are attenuated but stay at AA contrast', () => {
|
||||
test('older blocks keep the chosen reading size while staying at AA contrast', () => {
|
||||
const older = rule('#history .older');
|
||||
const size = Number.parseFloat(resolve(declaration(older, 'font-size')));
|
||||
const base = Number.parseFloat(resolve(declaration(body, 'font-size')));
|
||||
assert.ok(size <= base, 'older blocks must not grow');
|
||||
const main = Number.parseFloat(resolve(declaration(rule('.main-block'), 'font-size')));
|
||||
assert.equal(size, main, 'older blocks must keep the main reading size');
|
||||
assert.ok(contrast(resolve(declaration(older, 'color')), canvas) >= 4.5, 'older text must reach AA');
|
||||
});
|
||||
|
||||
test('help is indented under its main block, subdued and readable', () => {
|
||||
test('help leads with a larger size and missing help enlarges the main block', () => {
|
||||
const help = Number.parseFloat(resolve(declaration(rule('.help-group'), 'font-size')));
|
||||
const main = Number.parseFloat(resolve(declaration(rule('.main-block'), 'font-size')));
|
||||
const missing = Number.parseFloat(resolve(declaration(rule('.help-missing .main-block'), 'font-size')));
|
||||
assert.ok(help > main, 'associated help must lead the main text');
|
||||
assert.ok(missing > main, 'a main block without associated help must grow');
|
||||
});
|
||||
|
||||
test('help above the main block is indented, subdued and readable', () => {
|
||||
const help = rule('.help-group');
|
||||
assert.ok(declaration(help, 'padding-inline-start'), 'help must be indented');
|
||||
assert.match(declaration(help, 'border-inline-start'), /var\(--color-border\)/, 'help must carry a subordinate marker');
|
||||
@@ -125,9 +133,9 @@ test('the absent help marker is discreet and readable on the canvas and the curr
|
||||
}
|
||||
});
|
||||
|
||||
test('source line breaks are preserved and long lines wrap softly', () => {
|
||||
test('browser wrapping is natural and long lines wrap softly', () => {
|
||||
const entry = rule('#history li');
|
||||
assert.match(declaration(entry, 'white-space'), /pre-line/);
|
||||
assert.equal(declaration(entry, 'white-space'), 'normal');
|
||||
assert.match(declaration(entry, 'overflow-wrap'), /break-word|anywhere/);
|
||||
});
|
||||
|
||||
@@ -205,6 +213,8 @@ test('fullscreen clears the shell chrome and lets the reading fill the screen',
|
||||
const history = rule('main.is-fullscreen #history');
|
||||
assert.equal(declaration(history, 'flex'), '1 1 auto');
|
||||
assert.match(declaration(history, 'overflow-y'), /auto/, 'a tall reading must scroll inside the overlay');
|
||||
assert.equal(declaration(rule('main.is-fullscreen'), 'max-width'), 'none');
|
||||
assert.equal(declaration(rule('main.is-fullscreen'), 'width'), '100%');
|
||||
});
|
||||
|
||||
test('the minimal fullscreen bar carries an evident exit on the OLED canvas', () => {
|
||||
|
||||
Reference in new issue
Block a user