diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index c4929fe..4e0443f 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -6954,11 +6954,37 @@ return boxH; } - // Lyrics layout cache — measureText per syllable + row wrapping - // only changes when the displayed line(s), font size, or canvas - // width change, not per frame. Keyed below; the per-frame work is - // just drawing over the cached widths. - let _lyrRowsCache = null; + // Lyrics layout cache — measureText per syllable + width-splitting + // only changes when the lyric set, font size, or canvas width + // change, not per frame. Keyed below; the per-frame work is just + // windowing + drawing over the cached widths. + let _lyrLayoutCache = null; + + // Same live-tunable window config as the 2D highway + // (static/js/highway-draw.js getLyricsDisplayCfg) — duplicated + // because this plugin deliberately does not import the shared + // module. Both read localStorage['lyricsDisplay'], so + // highway.setLyricsDisplay() tunes both at once. + const LYRICS_DISPLAY_DEFAULTS = { upcomingLines: 2, lookaheadSec: 8 }; + let _lyrCfgRaw, _lyrCfg = LYRICS_DISPLAY_DEFAULTS; + function getLyricsDisplayCfg() { + let raw = null; + try { raw = localStorage.getItem('lyricsDisplay'); } catch (e) { /* storage denied */ } + if (raw === _lyrCfgRaw) return _lyrCfg; + _lyrCfgRaw = raw; + let parsed = null; + try { parsed = raw ? JSON.parse(raw) : null; } catch (e) { /* corrupt -> defaults */ } + if (!parsed || typeof parsed !== 'object') parsed = {}; + const num = (v, dflt, lo, hi) => { + const n = Number(v); + return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt; + }; + _lyrCfg = { + upcomingLines: num(parsed.upcomingLines, LYRICS_DISPLAY_DEFAULTS.upcomingLines, 0, 4) | 0, + lookaheadSec: num(parsed.lookaheadSec, LYRICS_DISPLAY_DEFAULTS.lookaheadSec, 1, 30), + }; + return _lyrCfg; + } function drawLyrics(lyrics, currentTime, ctx, W, H) { if (!lyrics._lines) { @@ -6985,41 +7011,29 @@ const allLines = lyrics._lines; if (!allLines.length) return 0; - let currentIdx = -1; - for (let i = 0; i < allLines.length; i++) { - if (allLines[i].start <= currentTime) currentIdx = i; - else break; - } - if (currentIdx === -1) { - if (allLines[0].start - currentTime > 2.0) return 0; - currentIdx = 0; - } - const currentLine = allLines[currentIdx]; - const nextLine = allLines[currentIdx + 1] || null; - const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity; - if (currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return 0; - - const linesToShow = [currentLine]; - if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine); - + const cfg = getLyricsDisplayCfg(); const fontSize = Math.max(18, H * 0.028) | 0; const lineY = H * 0.04; const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; }; ctx.font = `bold ${fontSize}px sans-serif`; - let rows, spaceWidth, bgWidth; - const _lc = _lyrRowsCache; - if (_lc && _lc.lyricsRef === lyrics && _lc.idx === currentIdx - && _lc.shown === linesToShow.length - && _lc.fontSize === fontSize && _lc.W === W) { - rows = _lc.rows; spaceWidth = _lc.spaceWidth; bgWidth = _lc.bgWidth; - } else { - spaceWidth = ctx.measureText(' ').width; - const maxWidth = W * 0.8; - rows = []; - for (const authoredLine of linesToShow) { - let row = [], rowWidth = 0; + // Display lines: authored lines pre-split at word boundaries so + // every display line fits maxWidth — one display line is exactly + // one rendered row, so a giant transcribed line scrolls through + // the window instead of wrapping into an unbounded block. + let layout = _lyrLayoutCache; + if (!layout || layout.lyricsRef !== lyrics || layout.fontSize !== fontSize || layout.W !== W) { + const spaceWidth = ctx.measureText(' ').width; + const maxWidth = W * 0.8; + const displayLines = []; + for (const authoredLine of allLines) { + let row = [], rowWidth = 0, start = null, end = null; + const flushRow = () => { + if (!row.length) return; + displayLines.push({ row, width: rowWidth - spaceWidth, start, end }); + row = []; rowWidth = 0; start = null; end = null; + }; for (const wordSyls of authoredLine.words) { const parts = []; let wordWidth = 0; @@ -7030,28 +7044,53 @@ wordWidth += w; } const advance = wordWidth + spaceWidth; - if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; } + if (row.length > 0 && rowWidth + advance > maxWidth) flushRow(); row.push({ parts, advance }); rowWidth += advance; + const first = wordSyls[0], last = wordSyls[wordSyls.length - 1]; + if (start === null) start = first.t; + end = end === null ? last.t + last.d : Math.max(end, last.t + last.d); } - if (row.length) rows.push(row); + flushRow(); } + layout = _lyrLayoutCache = { lyricsRef: lyrics, fontSize, W, spaceWidth, displayLines }; + } + const displayLines = layout.displayLines; + const spaceWidth = layout.spaceWidth; + if (!displayLines.length) return 0; - bgWidth = 0; - for (const row of rows) { - const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; - if (rw > bgWidth) bgWidth = rw; - } - bgWidth = Math.min(bgWidth + 30, W * 0.85); - _lyrRowsCache = { - lyricsRef: lyrics, idx: currentIdx, - shown: linesToShow.length, fontSize, W, - rows, spaceWidth, bgWidth, - }; + // Rolling window: current line + up to cfg.upcomingLines of + // context, each joining once it starts within cfg.lookaheadSec + // (also the pre-song preview window). + let currentIdx = -1; + for (let i = 0; i < displayLines.length; i++) { + if (displayLines[i].start <= currentTime) currentIdx = i; + else break; + } + const nextLine = displayLines[currentIdx + 1] || null; + if (currentIdx >= 0 + && currentTime > displayLines[currentIdx].end + 0.5 + && (!nextLine || nextLine.start - currentTime > cfg.lookaheadSec)) { + return 0; } + const maxLines = 1 + cfg.upcomingLines; + const startIdx = currentIdx === -1 ? 0 : currentIdx; + const shown = []; + for (let i = startIdx; i < displayLines.length && shown.length < maxLines; i++) { + if (i !== currentIdx && displayLines[i].start - currentTime > cfg.lookaheadSec) break; + shown.push(displayLines[i]); + } + if (!shown.length) return 0; + + let bgWidth = 0; + for (const dl of shown) { + if (dl.width > bgWidth) bgWidth = dl.width; + } + bgWidth = Math.min(bgWidth + 30, W * 0.85); + const rowHeight = fontSize + 6; - const totalHeight = rows.length * rowHeight + 10; + const totalHeight = shown.length * rowHeight + 10; ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.beginPath(); @@ -7069,10 +7108,9 @@ ctx.textAlign = 'left'; ctx.textBaseline = 'top'; - for (let r = 0; r < rows.length; r++) { - const row = rows[r]; - const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; - let xPos = W / 2 - rowWidth / 2; + for (let r = 0; r < shown.length; r++) { + const row = shown[r].row; + let xPos = W / 2 - shown[r].width / 2; const yPos = lineY + r * rowHeight + 2; for (const w of row) { for (const part of w.parts) { diff --git a/static/highway.js b/static/highway.js index 1913078..8413152 100644 --- a/static/highway.js +++ b/static/highway.js @@ -60,6 +60,7 @@ import { bsearchChords, drawChords, drawLyrics, + getLyricsDisplayCfg, drawNote, drawNotes, drawStrumGroups, @@ -2749,6 +2750,18 @@ function createHighway() { }, setOnLyricsChange(fn) { hwState._onLyricsChange = fn; }, + // Lyric display window (current + upcoming context). Live-tunable: + // takes effect on the next drawn frame, shared with the 3D highway + // via the same localStorage key. Partial updates merge over the + // current values; out-of-range values are clamped by the reader. + // highway.setLyricsDisplay({ upcomingLines: 3, lookaheadSec: 12 }) + getLyricsDisplay() { return { ...getLyricsDisplayCfg() }; }, + setLyricsDisplay(opts) { + const next = { ...getLyricsDisplayCfg(), ...(opts || {}) }; + localStorage.setItem('lyricsDisplay', JSON.stringify(next)); + return { ...getLyricsDisplayCfg() }; + }, + // Teaching marks (§6.2.2): toggle the opt-in sd/ch overlays. The fg // numeral has its own toggle below. Persisted to localStorage. getTeachingMarksVisible() { return hwState._showTeachingMarks; }, diff --git a/static/js/highway-draw.js b/static/js/highway-draw.js index 7b65490..af26b3f 100644 --- a/static/js/highway-draw.js +++ b/static/js/highway-draw.js @@ -921,11 +921,42 @@ export function drawChords(hwState, W, H) { } // ── Helpers ─────────────────────────────────────────────────────────── + +// Live-tunable lyric display window, persisted as JSON under +// localStorage['lyricsDisplay'] and settable in-game via +// highway.setLyricsDisplay({upcomingLines, lookaheadSec}). Read per frame +// behind a raw-string compare so a tweak takes effect on the next frame +// without a reload. +// upcomingLines — how many lines beyond the current one may be shown (0-4) +// lookaheadSec — how far ahead a line may start and still be previewed (1-30) +const LYRICS_DISPLAY_DEFAULTS = { upcomingLines: 2, lookaheadSec: 8 }; +let _lyricsCfgRaw; +let _lyricsCfg = LYRICS_DISPLAY_DEFAULTS; +export function getLyricsDisplayCfg() { + let raw = null; + try { raw = localStorage.getItem('lyricsDisplay'); } catch (e) { /* storage denied */ } + if (raw === _lyricsCfgRaw) return _lyricsCfg; + _lyricsCfgRaw = raw; + let parsed = null; + try { parsed = raw ? JSON.parse(raw) : null; } catch (e) { /* corrupt -> defaults */ } + if (!parsed || typeof parsed !== 'object') parsed = {}; + const num = (v, dflt, lo, hi) => { + const n = Number(v); + return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt; + }; + _lyricsCfg = { + upcomingLines: num(parsed.upcomingLines, LYRICS_DISPLAY_DEFAULTS.upcomingLines, 0, 4) | 0, + lookaheadSec: num(parsed.lookaheadSec, LYRICS_DISPLAY_DEFAULTS.lookaheadSec, 1, 30), + }; + return _lyricsCfg; +} + export function drawLyrics(hwState, W, H) { if (!hwState.lyrics.length) return; const fontSize = Math.max(18, H * 0.028) | 0; const lineY = H * 0.04; + const cfg = getLyricsDisplayCfg(); // Vocal markers: a trailing "-" means the syllable joins the // next one into a single word (no space); a trailing "+" marks the end @@ -975,67 +1006,88 @@ export function drawLyrics(hwState, W, H) { const allLines = hwState.lyrics._lines; if (!allLines.length) return; - // Current line = most recently started line. Before the first line has - // started, preview the first line if it's within 2s of starting. - let currentIdx = -1; - for (let i = 0; i < allLines.length; i++) { - if (allLines[i].start <= hwState.currentTime) currentIdx = i; - else break; - } - if (currentIdx === -1) { - if (allLines[0].start - hwState.currentTime > 2.0) return; - currentIdx = 0; - } - - const currentLine = allLines[currentIdx]; - const nextLine = allLines[currentIdx + 1] || null; - const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity; - - // Hide once the current line is clearly over and nothing relevant follows. - if (hwState.currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return; - - const linesToShow = [currentLine]; - if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine); - const sylText = (s) => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; }; hwState.ctx.font = `bold ${fontSize}px sans-serif`; - const spaceWidth = _measureLyricText(hwState, hwState.ctx, fontSize, ' '); const maxWidth = W * 0.8; - // Respect authored line breaks; wrap only if a line overflows maxWidth. - const rows = []; - for (const authoredLine of linesToShow) { - let row = [], rowWidth = 0; - for (const wordSyls of authoredLine.words) { - const parts = []; - let wordWidth = 0; - for (const s of wordSyls) { - const text = sylText(s); - const w = _measureLyricText(hwState, hwState.ctx, fontSize, text); - parts.push({ syl: s, text, width: w }); - wordWidth += w; + // Display lines: authored lines pre-split at word boundaries so every + // display line fits maxWidth — one display line is exactly one rendered + // row. This is what keeps a giant transcribed line (word-timed lyrics + // break only on long gaps) from blowing up into an unbounded wrap block: + // its segments become ordinary lines that scroll through the window + // below. Cached per (lyrics, fontSize, W); measure work never runs per + // frame. + let layout = hwState._lyricLayout; + if (!layout || layout.lyricsRef !== hwState.lyrics || layout.fontSize !== fontSize || layout.W !== W) { + const spaceWidth = _measureLyricText(hwState, hwState.ctx, fontSize, ' '); + const displayLines = []; + for (const authoredLine of allLines) { + let row = [], rowWidth = 0, start = null, end = null; + const flushRow = () => { + if (!row.length) return; + displayLines.push({ row, width: rowWidth - spaceWidth, start, end }); + row = []; rowWidth = 0; start = null; end = null; + }; + for (const wordSyls of authoredLine.words) { + const parts = []; + let wordWidth = 0; + for (const s of wordSyls) { + const text = sylText(s); + const w = _measureLyricText(hwState, hwState.ctx, fontSize, text); + parts.push({ syl: s, text, width: w }); + wordWidth += w; + } + const advance = wordWidth + spaceWidth; + if (row.length > 0 && rowWidth + advance > maxWidth) flushRow(); + row.push({ parts, advance }); + rowWidth += advance; + const first = wordSyls[0], last = wordSyls[wordSyls.length - 1]; + if (start === null) start = first.t; + end = end === null ? last.t + last.d : Math.max(end, last.t + last.d); } - const advance = wordWidth + spaceWidth; - if (row.length > 0 && rowWidth + advance > maxWidth) { - rows.push(row); - row = []; rowWidth = 0; - } - row.push({ parts, advance }); - rowWidth += advance; + flushRow(); } - if (row.length) rows.push(row); + layout = hwState._lyricLayout = { lyricsRef: hwState.lyrics, fontSize, W, spaceWidth, displayLines }; + } + const displayLines = layout.displayLines; + const spaceWidth = layout.spaceWidth; + if (!displayLines.length) return; + + // Rolling window: the current line plus up to cfg.upcomingLines of + // context. An upcoming line joins the window once it starts within + // cfg.lookaheadSec (this also serves as the pre-song preview window). + let currentIdx = -1; + for (let i = 0; i < displayLines.length; i++) { + if (displayLines[i].start <= hwState.currentTime) currentIdx = i; + else break; + } + const nextLine = displayLines[currentIdx + 1] || null; + // Hide once the current line is clearly over and nothing upcoming is + // close enough to preview. + if (currentIdx >= 0 + && hwState.currentTime > displayLines[currentIdx].end + 0.5 + && (!nextLine || nextLine.start - hwState.currentTime > cfg.lookaheadSec)) { + return; } + const maxLines = 1 + cfg.upcomingLines; + const startIdx = currentIdx === -1 ? 0 : currentIdx; + const shown = []; + for (let i = startIdx; i < displayLines.length && shown.length < maxLines; i++) { + if (i !== currentIdx && displayLines[i].start - hwState.currentTime > cfg.lookaheadSec) break; + shown.push(displayLines[i]); + } + if (!shown.length) return; + const rowHeight = fontSize + 6; - const totalHeight = rows.length * rowHeight + 10; + const totalHeight = shown.length * rowHeight + 10; let bgWidth = 0; - for (const row of rows) { - const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; - if (rw > bgWidth) bgWidth = rw; + for (const dl of shown) { + if (dl.width > bgWidth) bgWidth = dl.width; } bgWidth = Math.min(bgWidth + 30, W * 0.85); @@ -1046,10 +1098,9 @@ export function drawLyrics(hwState, W, H) { hwState.ctx.textAlign = 'left'; hwState.ctx.textBaseline = 'top'; - for (let r = 0; r < rows.length; r++) { - const row = rows[r]; - const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; - let xPos = W/2 - rowWidth/2; + for (let r = 0; r < shown.length; r++) { + const row = shown[r].row; + let xPos = W/2 - shown[r].width/2; const yPos = lineY + r * rowHeight + 2; for (const w of row) { diff --git a/tests/js/highway_lyrics_window.test.js b/tests/js/highway_lyrics_window.test.js new file mode 100644 index 0000000..09d5d6f --- /dev/null +++ b/tests/js/highway_lyrics_window.test.js @@ -0,0 +1,162 @@ +// Behavioural tests for the lyric display window (drawLyrics in +// static/js/highway-draw.js): width-based pre-splitting of long lines, +// the rolling current+upcoming window, its caps, and the live-tunable +// config reader. Extraction-by-source pattern per highway_teaching_marks. +// +// The 3D plugin (plugins/highway_3d/screen.js) carries a deliberate +// duplicate of this logic; these tests pin the canonical copy. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SRC = fs.readFileSync( + path.join(__dirname, '..', '..', 'static', 'js', 'highway-draw.js'), 'utf8'); + +function extractFn(src, name) { + const start = src.indexOf('function ' + name); + assert.ok(start >= 0, `function ${name} must exist`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return { text: src.slice(start, i + 1), end: i + 1 }; + } + throw new Error(`unbalanced braces extracting ${name}`); +} + +// getLyricsDisplayCfg with its module-level state: slice from the defaults +// const through the end of the function so the real declarations come along. +function loadCfgReader(storage) { + const constIdx = SRC.indexOf('const LYRICS_DISPLAY_DEFAULTS'); + assert.ok(constIdx >= 0); + const fn = extractFn(SRC, 'getLyricsDisplayCfg'); + const body = SRC.slice(constIdx, fn.end).replace(/^export /gm, ''); + return new Function('localStorage', '"use strict";' + body + '\nreturn getLyricsDisplayCfg;')(storage); +} + +function makeStorage(initial) { + const store = new Map(Object.entries(initial || {})); + return { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + }; +} + +test('cfg reader: defaults, clamping, corrupt JSON, live re-read', () => { + const storage = makeStorage(); + const read = loadCfgReader(storage); + assert.deepEqual(read(), { upcomingLines: 2, lookaheadSec: 8 }); + + storage.setItem('lyricsDisplay', JSON.stringify({ upcomingLines: 99, lookaheadSec: 0 })); + assert.deepEqual(read(), { upcomingLines: 4, lookaheadSec: 1 }); // clamped + + storage.setItem('lyricsDisplay', '{not json'); + assert.deepEqual(read(), { upcomingLines: 2, lookaheadSec: 8 }); // corrupt -> defaults + + storage.setItem('lyricsDisplay', JSON.stringify({ upcomingLines: 1 })); + assert.deepEqual(read(), { upcomingLines: 1, lookaheadSec: 8 }); // partial merges over defaults +}); + +// ── drawLyrics harness ─────────────────────────────────────────────────── +// Deps injected: _measureLyricText (10px per char), roundRect (noop), +// getLyricsDisplayCfg (test-controlled). ctx records fillText rows. +function loadDrawLyrics(cfg) { + const fn = extractFn(SRC, 'drawLyrics'); + return new Function( + '_measureLyricText', 'roundRect', 'getLyricsDisplayCfg', + '"use strict";' + fn.text + '\nreturn drawLyrics;' + )( + (hw, ctx, fs_, text) => text.length * 10, + () => {}, + () => cfg + ); +} + +function makeCtx() { + const calls = []; + return { + calls, + font: '', fillStyle: '', textAlign: '', textBaseline: '', + fillText: (text, x, y) => calls.push({ text, x, y }), + fill: () => {}, beginPath: () => {}, + measureText: (t) => ({ width: t.length * 10 }), + }; +} + +function rowsDrawn(ctx) { + return new Set(ctx.calls.map(c => c.y)).size; +} + +// Word-timed syllables, one per word, `plus` marks authored line ends. +function syl(t, w, plus) { return { t, d: 0.4, w: plus ? w + '+' : w }; } + +const H = 1000; // fontSize = max(18, 28) = 28 + +test('line-timed lyrics: current + upcoming context lines shown', () => { + // Four short authored lines, 2s apart — all inside an 8s lookahead. + const lyrics = [ + syl(10, 'one', true), syl(12, 'two', true), + syl(14, 'three', true), syl(16, 'four', true), + ]; + const draw = loadDrawLyrics({ upcomingLines: 2, lookaheadSec: 8 }); + const ctx = makeCtx(); + draw({ lyrics, ctx, currentTime: 10.1 }, 2000, H); + assert.equal(rowsDrawn(ctx), 3, 'current + 2 upcoming'); + assert.deepEqual(ctx.calls.map(c => c.text), ['one', 'two', 'three']); +}); + +test('upcomingLines: 0 shows only the current line', () => { + const lyrics = [syl(10, 'one', true), syl(12, 'two', true)]; + const draw = loadDrawLyrics({ upcomingLines: 0, lookaheadSec: 8 }); + const ctx = makeCtx(); + draw({ lyrics, ctx, currentTime: 10.1 }, 2000, H); + assert.equal(rowsDrawn(ctx), 1); + assert.deepEqual(ctx.calls.map(c => c.text), ['one']); +}); + +test('lookahead gates upcoming lines', () => { + // Next line 20s away — outside an 8s lookahead. + const lyrics = [syl(10, 'one', true), syl(30, 'far', true)]; + const draw = loadDrawLyrics({ upcomingLines: 2, lookaheadSec: 8 }); + const ctx = makeCtx(); + draw({ lyrics, ctx, currentTime: 10.1 }, 2000, H); + assert.deepEqual(ctx.calls.map(c => c.text), ['one']); +}); + +test('a giant unmarked line splits into rows capped by the window', () => { + // 40 words, no "+" anywhere, continuous timing (gaps < 4s): the old + // renderer wrapped all of it at once. Narrow canvas (W=300 → + // maxWidth=240) forces splits; the window must cap what is drawn at + // 1 current + 2 upcoming rows, never the whole blob. + const lyrics = []; + for (let i = 0; i < 40; i++) lyrics.push(syl(10 + i * 0.5, 'word' + i, false)); + const draw = loadDrawLyrics({ upcomingLines: 2, lookaheadSec: 8 }); + const ctx = makeCtx(); + draw({ lyrics, ctx, currentTime: 10.1 }, 300, H); + assert.ok(rowsDrawn(ctx) <= 3, `expected <=3 rows, got ${rowsDrawn(ctx)}`); + assert.ok(ctx.calls.length < 40, 'must not draw the entire blob'); + assert.equal(ctx.calls[0].text, 'word0', 'current segment starts the window'); +}); + +test('pre-song preview appears within lookahead, not before', () => { + const lyrics = [syl(10, 'one', true)]; + const draw = loadDrawLyrics({ upcomingLines: 2, lookaheadSec: 8 }); + + const early = makeCtx(); + draw({ lyrics, ctx: early, currentTime: 0 }, 2000, H); // 10s out > 8s + assert.equal(early.calls.length, 0); + + const near = makeCtx(); + draw({ lyrics, ctx: near, currentTime: 3 }, 2000, H); // 7s out <= 8s + assert.deepEqual(near.calls.map(c => c.text), ['one']); +}); + +test('banner hides after the last line ends with nothing upcoming', () => { + const lyrics = [syl(10, 'one', true)]; + const draw = loadDrawLyrics({ upcomingLines: 2, lookaheadSec: 8 }); + const ctx = makeCtx(); + draw({ lyrics, ctx, currentTime: 15 }, 2000, H); // ended at 10.4, +0.5 grace + assert.equal(ctx.calls.length, 0); +});