clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / frontend / dist / js / captions.js
captions.js 23.6K
1'use strict';23// Captions, drawn by the app, and audio tracks.45// ---------- captions ----------6// Captions follow the saved setting, except that programs whose sound7// isn't English start with them on. C during such a program turns them8// off or on for it alone, leaving the setting as it was.9const ENGLISH = /^(en|eng|english)([-_].*)?$/i;10const UNKNOWN = /^(|und|unknown|mul|mis|zxx|qaa)$/i;11const foreignAudio = (p) => !!p && !!p.audioLang && !ENGLISH.test(p.audioLang) && !UNKNOWN.test(p.audioLang);1213function programOn() {14 if (state.recording || !state.current) return null;15 return airingAt(state.current, Date.now()) || null;16}1718const programKey = (p) => `${state.current && state.current.key}|${p.start}`;1920function captionsWanted() {21 const p = programOn();22 if (foreignAudio(p)) {23 const o = state.captionsFor;24 return o && o.key === programKey(p) ? o.on : true;25 }26 return !!state.settings.captions;27}2829// The app draws captions itself, the same on macOS and Linux, from the30// stream's text track, which it keeps hidden from the webview: a custom31// channel's WebVTT subtitles (timed word by word when YouTube's speech32// recognition made them), else a broadcast's CEA-608 captions as hls.js or33// WebKit decode them. They sit low on the picture, above the banner while34// it shows, sized to the picture and the interface size: at most two35// lines, as even as they'll go, broken where the source broke them when36// that fits. A caption too long for two lines is paged, each page filling37// the box in turn, and timed words appear as they're said. Pretext38// (vendor/pretext) measures the words; canvas does until it has loaded,39// or if it can't.40const CAP_FAMILY = '"Schibsted", "Helvetica Neue", sans-serif';41const CAP_HEIGHT = 0.046; // font size, of the picture's height at 100%42const CAP_EMS = 21; // the widest line, in ems: about 42 characters43const cap = {44 el: $('#captions'),45 track: null, // the text track drawn46 timedTrack: false, // whether it times words47 checked: 0, // how many of its cues that's from48 m: null, // sizes: { size, max, bottom, pad, font, key }49 key: '', // the page drawn, at its size50 page: null,51 shown: -1, // how many of its words show52 spans: [], // its word elements, timed pages only53 bars: [],54 logical: null, // the track's cues, whole: { sig, cues }55 timed: null, // a timed track's words, paged56 frozen: new Map(),// timed pages shown, as they were laid out57 units: new Map(), // untimed captions, paged, by cue58 raf: 0,59 ready: false, // the caption font has loaded60};61let pretext = null;62import('../vendor/pretext/rich-inline.js') // from this file's folder63 .then((m) => { pretext = m; resetCaptionLayout(); })64 .catch((e) => log('info', `captions measured with canvas: ${e}`));65document.fonts.load(`500 32px ${CAP_FAMILY}`).catch(() => {}).then(() => { cap.ready = true; resetCaptionLayout(); });6667function captionTracks() {68 return [...(video.textTracks || [])].filter((t) => t.kind === 'captions' || t.kind === 'subtitles');69}7071// applyCaptions shows or hides captions as wanted, from the text subtitles72// when there are some (a custom channel has those and the same words as73// 608 in the picture), else the captions in the picture. It reports74// whether the stream has any.75function applyCaptions() {76 const { tracks, pick, on } = captionPlan();77 for (const t of tracks) {78 const mode = on && t === pick ? 'hidden' : 'disabled';79 if (t.mode !== mode) t.mode = mode;80 }81 // hls.js stops fetching subtitles nobody's reading.82 if (!on && state.hls && state.hls.subtitleTrack !== -1) state.hls.subtitleTrack = -1;83 setCaptionTrack(on ? pick : null);84 return !!pick;85}8687// captionPlan is which tracks there are, which one captions come from,88// and whether they're wanted.89function captionPlan() {90 const tracks = captionTracks();91 const pick = tracks.find((t) => t.kind === 'subtitles') || tracks[0] || null;92 return { tracks, pick, on: !!state.settings && captionsWanted() };93}9495// Only the app turns captions on: when a player or the webview changes a96// track (picking one itself, or following a system caption setting), the97// app sets them back as wanted.98function keepCaptions() {99 const { tracks, pick, on } = captionPlan();100 if (tracks.some((t) => t.mode !== (on && t === pick ? 'hidden' : 'disabled'))) applyCaptions();101}102103async function toggleCaptions() {104 const p = programOn();105 if (foreignAudio(p)) state.captionsFor = { key: programKey(p), on: !captionsWanted() };106 else await saveAppSettings({ ...state.settings, captions: !state.settings.captions });107 const found = applyCaptions();108 toast(captionsWanted() ? (found ? 'Captions on' : 'Captions on (this program has none)') : 'Captions off', 1800);109}110111function setCaptionTrack(t) {112 if (t === cap.track) return;113 cap.track = t;114 cap.timedTrack = false;115 cap.checked = 0;116 cap.logical = null;117 cap.timed = null;118 cap.frozen.clear();119 cap.units.clear();120 drawCaption(null);121 if (t && !cap.raf) cap.raf = requestAnimationFrame(captionFrame);122}123124// resetCaptionLayout lays captions out again: the picture, the banner or125// the font changed.126function resetCaptionLayout() {127 cap.m = null;128 cap.key = '';129}130131function captionFrame() {132 cap.raf = 0;133 if (!cap.track) return;134 if (state.view === 'tv' && cap.ready) renderCaption(video.currentTime);135 cap.raf = requestAnimationFrame(captionFrame);136}137138// captionSizes fits captions to the picture within the stage, in CSS139// pixels: the font a share of the picture's height, scaled with the140// interface, and the lines' bottom a little above the picture's, or above141// the banner or the weather crawl.142function captionSizes() {143 const w = stage.clientWidth;144 const h = stage.clientHeight;145 if (!w || !h) return null;146 const ar = video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 16 / 9;147 const pw = Math.min(w, h * ar);148 const ph = pw / ar;149 // Larger with the interface, though only half as fast: at 150% they'd150 // otherwise crowd the picture.151 const z = 1 + (((state.settings && state.settings.scale) || autoScale()) / 100 - 1) / 2;152 const size = Math.max(12, Math.round(ph * CAP_HEIGHT * z));153 const max = Math.round(Math.min(pw * 0.88, size * CAP_EMS));154 let bottom = (h - ph) / 2 + ph * 0.055;155 const banner = $('#banner');156 if (state.view === 'tv' && banner.classList.contains('show')) bottom = Math.max(bottom, h - banner.offsetTop + size * 0.45);157 const crawl = $('#crawl');158 if (crawl && document.body.classList.contains('has-crawl')) bottom = Math.max(bottom, crawl.offsetHeight + size * 0.45);159 return { size, max, bottom: Math.round(bottom), pad: size * 0.3, font: `500 ${size}px ${CAP_FAMILY}`, key: `${size}/${max}` };160}161162function renderCaption(t) {163 if (!cap.m) {164 cap.m = captionSizes();165 if (!cap.m) return;166 cap.el.style.fontSize = `${cap.m.size}px`;167 cap.el.style.bottom = `${cap.m.bottom}px`;168 }169 const track = cap.track;170 const n = track.cues ? track.cues.length : 0;171 if (!cap.timedTrack && n !== cap.checked) {172 cap.timedTrack = timesWords(track);173 cap.checked = n;174 }175 const at = cap.timedTrack ? timedPage(track, t) : untimedPage(track, t);176 drawCaption(at, t);177}178179// ----- cue text -----180const VTT_TIME = /^<(?:(\d+):)?(\d\d):(\d\d)\.(\d{3})>$/;181const CUE_ENTITIES = { amp: '&', lt: '<', gt: '>', nbsp: ' ', quot: '"', apos: "'", lrm: '', rlm: '' };182const decodeCue = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, e) => {183 if (e[0] !== '#') return CUE_ENTITIES[e.toLowerCase()] ?? all;184 return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));185}).replace(/¶/g, '♪'); // a music note, as captions made from broadcast ones misname it186const parsedCues = new Map(); // by start and text187188function cueText(cue) {189 if (typeof cue.text === 'string') return cue.text;190 return cue.getCueAsHTML ? cue.getCueAsHTML().textContent : '';191}192193// logicalCues is a track's cues as the stream sent them, by start. WebKit194// splits a cue that shows alongside others into pieces with its text, one195// for each stretch the set showing stays the same; they're joined again.196function logicalCues(track) {197 const list = track.cues;198 const n = list ? list.length : 0;199 const sig = n ? `${n}|${list[0].startTime}|${list[n - 1].startTime}` : '';200 if (cap.logical && cap.logical.sig === sig) return cap.logical.cues;201 const cues = [];202 const open = new Map();203 for (let i = 0; i < n; i++) {204 const c = list[i];205 const text = cueText(c);206 const l = open.get(text);207 if (l && c.startTime <= l.endTime + 0.05) {208 l.endTime = Math.max(l.endTime, c.endTime);209 continue;210 }211 const cue = { startTime: c.startTime, endTime: c.endTime, text, line: c.line };212 open.set(text, cue);213 cues.push(cue);214 }215 cap.logical = { sig, cues };216 return cues;217}218219// cueLines reads a cue into lines of words, each with when it's said:220// the cue's start, or the WebVTT timestamp before it.221function cueLines(cue) {222 const id = `${cue.startTime}\n${cue.text}`;223 let lines = parsedCues.get(id);224 if (lines) {225 for (const l of lines) for (const w of l) w.end = cue.endTime;226 return lines;227 }228 if (parsedCues.size > 5000) parsedCues.clear();229 lines = [];230 for (const raw of cue.text.split(/\r?\n/)) {231 let at = cue.startTime;232 const words = [];233 for (const part of raw.split(/(<[^>]*>)/)) {234 if (part[0] === '<') {235 const m = VTT_TIME.exec(part);236 if (m) at = (+m[1] || 0) * 3600 + +m[2] * 60 + +m[3] + +m[4] / 1000;237 continue;238 }239 for (const w of decodeCue(part).split(/\s+/)) {240 if (w) words.push({ text: w, at, end: cue.endTime });241 }242 }243 if (words.length) lines.push(words);244 }245 parsedCues.set(id, lines);246 return lines;247}248249// timesWords reports whether a track's captions time their words, as250// speech recognition's do (though not every cue: a one-word line has no251// time but its start).252function timesWords(track) {253 for (let i = 0; i < Math.min(track.cues.length, 40); i++) {254 if (/<(\d+:)?\d\d:\d\d\.\d{3}>/.test(cueText(track.cues[i]))) return true;255 }256 return false;257}258259// ----- measuring -----260const capWidths = new Map(); // font and word: width261let capCanvas = null;262263function canvasFont(font) {264 if (!capCanvas) capCanvas = document.createElement('canvas').getContext('2d');265 capCanvas.font = font;266 return capCanvas;267}268269// measureWords returns the width of each word in font.270function measureWords(words, font) {271 if (capWidths.size > 20000) capWidths.clear();272 const out = new Array(words.length);273 const todo = [];274 words.forEach((w, i) => {275 const v = capWidths.get(`${font}\n${w}`);276 if (v === undefined) todo.push(i); else out[i] = v;277 });278 if (todo.length && pretext) {279 const p = pretext.prepareRichInline(todo.map((i, k) => ({ text: (k ? ' ' : '') + words[i], font, break: 'never' })));280 pretext.walkRichInlineLineRanges(p, 1e9, (range) => {281 for (const fr of pretext.materializeRichInlineLineRange(p, range).fragments) out[todo[fr.itemIndex]] = fr.occupiedWidth;282 });283 }284 for (const i of todo) {285 if (out[i] === undefined) out[i] = canvasFont(font).measureText(words[i]).width;286 capWidths.set(`${font}\n${words[i]}`, out[i]);287 }288 return out;289}290291const spaceWidth = (font) => measureWords(['a\u00a0a', 'aa'], font).reduce((x, y) => x - y);292293// ----- laying out -----294const SENTENCE_END = /[.!?…♪]["'”’)\]]*$/;295const CLAUSE_END = /[,;:.!?…—]["'”’)\]]*$/;296297// pageWords splits words from..to into pages of at most two lines no298// wider than max, each as full as it goes but ending a sentence where one299// ends late in it, and the last not left with a word or two. A word300// marked brk starts a line (a change of speaker).301function pageWords(words, widths, space, max, from = 0, to = words.length) {302 // fill is where a page from i ends when filled.303 const fill = (i) => {304 let line = 1;305 let start = i;306 let w = 0;307 for (let j = i; j < to; j++) {308 if (j > start && (words[j].brk || w + space + widths[j] > max)) {309 if (line === 2) return j;310 line = 2;311 start = j;312 w = widths[j];313 } else {314 w += (j > start ? space : 0) + widths[j];315 }316 }317 return to;318 };319 const ranges = [];320 for (let i = from; i < to;) {321 let end = fill(i);322 if (end < to) {323 for (let k = end - 1; k > i + (end - i) / 2; k--) {324 if (SENTENCE_END.test(words[k].text)) { end = k + 1; break; }325 }326 }327 ranges.push([i, end]);328 i = end;329 }330 const chars = (a, b) => { let n = 0; for (let i = a; i < b; i++) n += words[i].text.length + 1; return n; };331 const n = ranges.length;332 if (n > 1 && chars(...ranges[n - 1]) < 0.35 * chars(...ranges[n - 2])) {333 // Even out the last two pages, splitting after punctuation if near.334 const [a] = ranges[n - 2];335 const b = ranges[n - 1][1];336 let best = Infinity;337 for (let k = a + 1; k < b; k++) {338 if (fill(a) < k || fill(k) < b) continue;339 const c = Math.abs(chars(a, k) - chars(k, b)) - (CLAUSE_END.test(words[k - 1].text) ? 8 : 0);340 if (c < best) { best = c; ranges[n - 2] = [a, k]; ranges[n - 1] = [k, b]; }341 }342 }343 return ranges.map(([a, b]) => balance(words, widths, space, max, a, b));344}345346// balance puts words a..b on one line if they fit, else two of about347// equal width, breaking after punctuation where it can, at a change of348// speaker where there is one.349function balance(words, widths, space, max, a, b) {350 const sum = [0];351 for (let i = a; i < b; i++) sum.push(sum[sum.length - 1] + widths[i] + space);352 const width = (x, y) => sum[y - a] - sum[x - a] - space;353 for (let k = a + 1; k < b; k++) {354 if (words[k].brk) return { from: a, to: b, lines: [[a, k], [k, b]] };355 }356 if (width(a, b) <= max || b - a < 2) return { from: a, to: b, lines: [[a, b]] };357 let best = Infinity;358 let at = a + 1;359 for (let k = a + 1; k < b; k++) {360 const l = width(a, k);361 const r = width(k, b);362 if (l > max || r > max) continue;363 const c = Math.abs(l - r) + (l > r ? space : 0) - (CLAUSE_END.test(words[k - 1].text) ? 3 * space : 0);364 if (c < best) { best = c; at = k; }365 }366 return { from: a, to: b, lines: [[a, at], [at, b]] };367}368369// speakerMark reports whether a word marks a change of speaker, as370// speech recognition and broadcast captions do with ">>".371const speakerMark = (w) => w.startsWith('>>');372373// ----- choosing what shows -----374// cuesAt lists a track's cues showing at t, top row first.375function cuesAt(track, t) {376 const list = logicalCues(track);377 let lo = 0;378 let hi = list.length - 1;379 let i = -1;380 while (lo <= hi) {381 const mid = (lo + hi) >> 1;382 if (list[mid].startTime <= t) { i = mid; lo = mid + 1; } else hi = mid - 1;383 }384 const out = [];385 for (let k = i; k >= 0 && k > i - 40; k--) if (list[k].endTime > t) out.push(list[k]);386 const row = (c) => (typeof c.line === 'number' ? c.line : 0);387 return out.sort((x, y) => row(x) - row(y) || x.startTime - y.startTime);388}389390// untimedPage is what shows at t of captions without word times: the391// cues showing, laid out together and paged over their time.392function untimedPage(track, t) {393 const cues = cuesAt(track, t);394 if (!cues.length) return null;395 const key = cues.map((c) => `${c.startTime}/${c.text}`).join('\n');396 let u = cap.units.get(key);397 if (!u || u.end !== Math.max(...cues.map((c) => c.endTime))) {398 if (cap.units.size > 200) cap.units.clear();399 const m = cap.m;400 // Overlapping cues showing the same line show it once, oldest first.401 const seen = new Set();402 const lines = cues.flatMap(cueLines).filter((l) => {403 const text = l.map((w) => w.text).join(' ');404 return !seen.has(text) && seen.add(text);405 });406 const words = [];407 for (const [i, l] of lines.entries()) {408 for (const [k, w] of l.entries()) words.push({ ...w, brk: (k === 0 && i > 0 && /^[-–—]/.test(w.text)) || speakerMark(w.text) });409 }410 const widths = measureWords(words.map((w) => w.text), m.font);411 const space = spaceWidth(m.font);412 // Lines that fit stay as the source broke them.413 let pages;414 const lineWidth = (from, n) => widths.slice(from, from + n).reduce((s, x) => s + x, 0) + (n - 1) * space;415 let from = 0;416 if (lines.length <= 2 && lines.every((l) => { const ok = lineWidth(from, l.length) <= m.max; from += l.length; return ok; })) {417 const cut = lines[0].length;418 pages = [{ from: 0, to: words.length, lines: lines.length === 2 ? [[0, cut], [cut, words.length]] : [[0, words.length]] }];419 } else {420 pages = pageWords(words, widths, space, m.max);421 }422 // Pages share the cues' time out by length.423 const start = Math.min(...cues.map((c) => c.startTime));424 const end = Math.max(...cues.map((c) => c.endTime));425 const chars = (p) => words.slice(p.from, p.to).reduce((s, w) => s + w.text.length + 1, 0);426 const total = pages.reduce((s, p) => s + chars(p), 0);427 let done = 0;428 for (const [i, p] of pages.entries()) {429 p.start = start + ((end - start) * done) / total;430 done += chars(p);431 p.end = i === pages.length - 1 ? end : start + ((end - start) * done) / total;432 p.id = `${key}#${i}`;433 }434 u = { words, widths, space, pages, end };435 cap.units.set(key, u);436 }437 const page = u.pages.find((p) => p.start <= t && t < p.end) || u.pages[u.pages.length - 1];438 return { page, words: u.words, widths: u.widths, space: u.space };439}440441// timedPage is what shows at t of captions timed word by word: the442// track's words, in runs of speech split at pauses, paged; a page shows443// from its first word until the next page's, its words as they're said.444// Captions arrive a little ahead of the picture, so a page is laid out445// with what's known then; once it shows it stays as it is (frozen), and446// words that come later go on the pages after.447const wordKey = (w) => `${w.at}\n${w.text}`;448449function timedPage(track, t) {450 const m = cap.m;451 const list = logicalCues(track);452 if (!list.length) return null;453 const sig = `${cap.logical.sig}|${m.key}`;454 if (!cap.timed || cap.timed.sig !== sig) {455 const all = [];456 // A cue's last line is its own: the server's screens roll up, the457 // line above repeating the cue before's, which other sources overlap.458 for (const c of list) {459 const lines = cueLines(c);460 if (lines.length) all.push(...lines[lines.length - 1]);461 }462 all.sort((x, y) => x.at - y.at);463 const words = [];464 for (const w of all) {465 const last = words[words.length - 1];466 if (last && last.at === w.at && last.text === w.text) last.end = Math.max(last.end, w.end);467 else words.push({ ...w, brk: speakerMark(w.text) });468 }469 const widths = measureWords(words.map((w) => w.text), m.font);470 const space = spaceWidth(m.font);471 const pages = [];472 for (let a = 0; a < words.length;) {473 let b = a + 1;474 while (b < words.length && words[b].at <= words[b - 1].end + 0.25 && words[b].at - words[b - 1].at < 4) b++;475 const run = [];476 let i = a;477 // Pages already shown, as they were.478 for (let f = cap.frozen.get(wordKey(words[i])); f && i < b; f = i < b && cap.frozen.get(wordKey(words[i]))) {479 let j = i;480 while (j < b && wordKey(words[j]) !== f.last) j++;481 if (j === b) break;482 let k = -1;483 for (let x = i + 1; x <= j; x++) if (f.brk && wordKey(words[x]) === f.brk) k = x;484 run.push({ from: i, to: j + 1, lines: k > 0 ? [[i, k], [k, j + 1]] : [[i, j + 1]] });485 i = j + 1;486 }487 if (i < b) run.push(...pageWords(words, widths, space, m.max, i, b));488 for (const [k, p] of run.entries()) {489 p.start = words[p.from].at;490 let end = 0;491 for (let x = p.from; x < p.to; x++) end = Math.max(end, words[x].end);492 p.end = k + 1 < run.length ? words[run[k + 1].from].at : Math.max(end, p.start + 1);493 p.id = `${wordKey(words[p.from])}/${p.to - p.from}`;494 pages.push(p);495 }496 a = b;497 }498 cap.timed = { sig, words, widths, space, pages };499 }500 const { pages, words } = cap.timed;501 let lo = 0;502 let hi = pages.length - 1;503 let i = -1;504 while (lo <= hi) {505 const mid = (lo + hi) >> 1;506 if (pages[mid].start <= t) { i = mid; lo = mid + 1; } else hi = mid - 1;507 }508 if (i < 0 || t >= pages[i].end) return null;509 const page = pages[i];510 if (!cap.frozen.has(wordKey(words[page.from]))) {511 if (cap.frozen.size > 400) cap.frozen.delete(cap.frozen.keys().next().value);512 const [, second] = page.lines;513 cap.frozen.set(wordKey(words[page.from]), { last: wordKey(words[page.to - 1]), brk: second ? wordKey(words[second[0]]) : '' });514 }515 return { page, words, widths: cap.timed.widths, space: cap.timed.space, timed: true };516}517518// ----- drawing -----519function drawCaption(at, t) {520 const key = at ? `${at.page.id}@${cap.m.key}` : '';521 if (key !== cap.key) {522 cap.key = key;523 cap.page = at;524 cap.shown = -1;525 cap.spans = [];526 cap.bars = [];527 cap.el.textContent = '';528 if (!at) return;529 for (const [a, b] of at.page.lines) {530 const line = document.createElement('div');531 line.className = 'cap-line';532 const bar = document.createElement('span');533 bar.className = at.timed ? 'cap-bar timed' : 'cap-bar';534 if (at.timed) {535 for (let i = a; i < b; i++) {536 const s = document.createElement('span');537 s.textContent = (i > a ? ' ' : '') + at.words[i].text;538 bar.append(s);539 cap.spans.push({ el: s, i, bar, a });540 }541 cap.bars.push({ bar, a, b });542 } else {543 bar.textContent = at.words.slice(a, b).map((w) => w.text).join(' ');544 }545 line.append(bar);546 cap.el.append(line);547 }548 }549 if (!at || !at.timed) return;550 // Timed words show as they're said, with the bar behind them growing.551 const { page, words, widths, space } = at;552 let n = page.from;553 while (n < page.to && words[n].at <= t) n++;554 if (n === cap.shown) return;555 cap.shown = n;556 for (const s of cap.spans) s.el.classList.toggle('wait', s.i >= n);557 for (const { bar, a, b } of cap.bars) {558 let w = 0;559 for (let i = a; i < Math.min(b, n); i++) w += widths[i] + (i > a ? space : 0);560 bar.style.setProperty('--r', n > a ? `${Math.ceil(w + 2 * cap.m.pad + 2)}px` : '0px');561 }562}563564// A new stream: the last one's captions go at once, not when its tracks do.565video.addEventListener('emptied', () => setCaptionTrack(null));566if (window.ResizeObserver) new ResizeObserver(resetCaptionLayout).observe(stage);567video.addEventListener('resize', resetCaptionLayout);568new MutationObserver(resetCaptionLayout).observe($('#banner'), { attributes: true, attributeFilter: ['class'] });569new MutationObserver(resetCaptionLayout).observe(document.body, { attributes: true, attributeFilter: ['class'] });570571function audioTracks() {572 if (state.hls) return state.hls.audioTracks.map((t, i) => ({ i, label: t.name, lang: t.lang, on: state.hls.audioTrack === i }));573 const list = video.audioTracks;574 if (!list) return [];575 const out = [];576 for (let i = 0; i < list.length; i++) out.push({ i, label: list[i].label, lang: list[i].language, on: list[i].enabled });577 return out;578}579580function selectAudio(i) {581 if (state.hls) { state.hls.audioTrack = i; return; }582 const list = video.audioTracks;583 for (let j = 0; j < list.length; j++) list[j].enabled = j === i;584}585586// applyAudio picks the preferred language when the stream offers it.587function applyAudio() {588 const want = state.settings.audioLang;589 if (!want) return;590 const t = audioTracks().find((x) => (x.lang || '').startsWith(want));591 if (t && !t.on) selectAudio(t.i);592}593594async function cycleAudio() {595 const tracks = audioTracks();596 if (tracks.length < 2) return toast('Only one audio track on this channel', 1800);597 const cur = tracks.findIndex((t) => t.on);598 const next = tracks[(cur + 1) % tracks.length];599 selectAudio(next.i);600 await saveAppSettings({ ...state.settings, audioLang: (next.lang || '').slice(0, 2) });601 toast(`Audio: ${next.label || next.lang || `track ${next.i + 1}`}`, 1800);602}