/* =====================================================================
   daily-word-core.js  —  v5.1 (re-extracted from the PATCHED prototype)
   Extracted verbatim from "Reading Plan Builder v2.dc.html" lines 308–558
   (constructor … dateOf). renderVals() is deliberately NOT included: it is
   the DC view-model and gets rewritten as render-to-HTML functions.

   Nothing below the SHIM banner has been retyped or edited. If a line here
   differs from the prototype, that is a bug in the extraction, not a port
   decision.
   ===================================================================== */

/* ---------- DC SHIM (replaces DCLogic + localStorage under Node/Rock) ---------- */
class DCLogic {
  constructor(props) { this.props = props || {}; this.state = {}; }
  setState(patch, cb) {
    const p = (typeof patch === "function") ? patch(this.state) : patch;
    if (p) Object.assign(this.state, p);
    if (cb) cb();
  }
}

// Prototype's load()/save() read a global `localStorage`. Under Node it is
// absent (ReferenceError -> caught -> returns null), which is why the class
// runs unmodified. Provide a memory store so save/load round-trips testably.
if (typeof globalThis.localStorage === "undefined") {
  const mem = new Map();
  globalThis.localStorage = {
    getItem: (k) => (mem.has(k) ? mem.get(k) : null),
    setItem: (k, v) => { mem.set(k, String(v)); },
    removeItem: (k) => { mem.delete(k); },
    clear: () => mem.clear()
  };
}

/* ---------- END SHIM — verbatim extraction begins ---------- */

class Component extends DCLogic {
  constructor(props) {
    super(props);
    this.API = "https://rest.api.bible/v1";
    this.TEXT_BIBLE = "78a9f6124f344018-01"; // NIV 2011
    this.BOOKS = [
      ["Genesis",50],["Exodus",40],["Leviticus",27],["Numbers",36],["Deuteronomy",34],
      ["Joshua",24],["Judges",21],["Ruth",4],["1 Samuel",31],["2 Samuel",24],
      ["1 Kings",22],["2 Kings",25],["1 Chronicles",29],["2 Chronicles",36],["Ezra",10],
      ["Nehemiah",13],["Esther",10],["Job",42],["Psalms",150],["Proverbs",31],
      ["Ecclesiastes",12],["Song of Songs",8],["Isaiah",66],["Jeremiah",52],["Lamentations",5],
      ["Ezekiel",48],["Daniel",12],["Hosea",14],["Joel",3],["Amos",9],
      ["Obadiah",1],["Jonah",4],["Micah",7],["Nahum",3],["Habakkuk",3],
      ["Zephaniah",3],["Haggai",2],["Zechariah",14],["Malachi",4],
      ["Matthew",28],["Mark",16],["Luke",24],["John",21],["Acts",28],
      ["Romans",16],["1 Corinthians",16],["2 Corinthians",13],["Galatians",6],["Ephesians",6],
      ["Philippians",4],["Colossians",4],["1 Thessalonians",5],["2 Thessalonians",3],["1 Timothy",6],
      ["2 Timothy",4],["Titus",3],["Philemon",1],["Hebrews",13],["James",5],
      ["1 Peter",5],["2 Peter",3],["1 John",5],["2 John",1],["3 John",1],
      ["Jude",1],["Revelation",22]
    ];
    const names = this.BOOKS.map(b => b[0]);
    const range = (a, b) => names.slice(names.indexOf(a), names.indexOf(b) + 1);
    this.PRESETS = [
      { name: "Whole Bible", books: names },
      { name: "Old Testament", books: range("Genesis","Malachi") },
      { name: "New Testament", books: range("Matthew","Revelation") },
      { name: "Pentateuch", books: range("Genesis","Deuteronomy") },
      { name: "OT history", books: range("Joshua","Esther") },
      { name: "Wisdom & poetry", books: range("Job","Song of Songs") },
      { name: "Psalms", books: ["Psalms"] },
      { name: "Proverbs", books: ["Proverbs"] },
      { name: "Prophets", books: range("Isaiah","Malachi") },
      { name: "Major prophets", books: range("Isaiah","Daniel") },
      { name: "Minor prophets", books: range("Hosea","Malachi") },
      { name: "Gospels", books: range("Matthew","John") },
      { name: "Acts", books: ["Acts"] },
      { name: "Paul's letters", books: range("Romans","Philemon") },
      { name: "General letters", books: range("Hebrews","Jude") },
      { name: "Epistles", books: range("Romans","Jude") },
      { name: "Revelation", books: ["Revelation"] }
    ];
    this.FREQ_STEPS = [0.5, 1, 2, 3, 4, 6, 12, 24, 52];
    this.TEMPLATES = [
      { name: "Whole Bible in a year", desc: "Cover to cover — about 3¼ chapters a day.", sels: [
        { name: "Whole Bible", baseName: "Whole Bible", books: names, freq: 1 }] },
      // M'Cheyne's 1842 calendar is four parallel bookmarks, not three streams
      // weighted by frequency. Each track is ~365-410 chapters read once, which
      // is why the original has a reading in every track every day. Modelling it
      // as "Psalms x2" instead left 65 days with no psalm. Book ORDER inside a
      // track is load-bearing: dayChapters walks sel.books as given, so placing
      // Psalms mid-track is what makes the tracks open on Gen 1 / Mt 1 / Ezra 1 /
      // Acts 1 and close on 2 Chr 36 / Rev 22 / Mal 4 / Jn 21, as the printed
      // calendar does.
      { name: "M'Cheyne", desc: "The classic 1842 Scottish calendar: four bookmarks — OT once, NT and Psalms twice — about 4 chapters a day.", sels: [
        { name: "Family · Genesis onward", baseName: "M'Cheyne family 1",
          books: range("Genesis","2 Chronicles"), freq: 1 },
        { name: "Family · Matthew onward", baseName: "M'Cheyne family 2",
          books: [...range("Matthew","John"), "Psalms", ...range("Acts","Revelation")], freq: 1 },
        { name: "Secret · Ezra onward", baseName: "M'Cheyne secret 1",
          books: range("Ezra","Malachi").filter(n => n !== "Psalms"), freq: 1 },
        { name: "Secret · Acts onward", baseName: "M'Cheyne secret 2",
          books: [...range("Acts","Revelation"), "Psalms", ...range("Matthew","John")], freq: 1 }] },
      { name: "New Testament in a year", desc: "A gentle pace — one chapter most days.", sels: [
        { name: "New Testament", baseName: "New Testament", books: range("Matthew","Revelation"), freq: 1 }] },
      { name: "Psalms & Proverbs monthly", desc: "The historic wisdom habit: five psalms and a proverb a day, twelve times a year.", sels: [
        { name: "Psalms", baseName: "Psalms", books: ["Psalms"], freq: 12 },
        { name: "Proverbs", baseName: "Proverbs", books: ["Proverbs"], freq: 12 }] },
      { name: "Gospels every season", desc: "Live in the life of Jesus — all four Gospels, four times a year.", sels: [
        { name: "Gospels", baseName: "Gospels", books: range("Matthew","John"), freq: 4 }] },
      { name: "Whole Bible in 90 days", desc: "Intensive: about 13 chapters a day, four passes in a year.", sels: [
        { name: "Whole Bible", baseName: "Whole Bible", books: names, freq: 4 }] }
    ];
    const saved = this.load();
    this.state = saved || {
      view: "setup",
      selections: [
        { name: "Psalms", books: ["Psalms"], freq: 3 },
        { name: "Gospels", books: range("Matthew","John"), freq: 4 },
        { name: "Acts", books: ["Acts"], freq: 12 },
        { name: "Rest of OT", baseName: "Old Testament", books: range("Genesis","Malachi").filter(n => n !== "Psalms"), freq: 0.5 }
      ],
      startDate: null, checks: {}, viewDay: 0
    };
    if (this.state.follow === undefined) this.state.follow = true;
  }

  LEAD = 0; // the estimate already accounts for the spoken intro

  apiHeaders() { return { headers: { "api-key": "ihqFe6Ulyut2QBDL8qqXX" } }; }

  load() {
    try {
      const raw = localStorage.getItem("gnc-reading-plan");
      if (!raw) return null;
      const s = JSON.parse(raw);
      if (s && s.startDate) s.viewDay = this.todayIndex(s.startDate);
      if (s && s.view === "read") s.view = "today";
      return s;
    } catch (e) { return null; }
  }
  save() {
    const { view, selections, startDate, checks, viewDay, readerFont, follow, speed } = this.state;
    try { localStorage.setItem("gnc-reading-plan", JSON.stringify({ view, selections, startDate, checks, viewDay, readerFont, follow, speed })); } catch (e) {}
  }
  setSt(patch) { this.setState(patch, () => this.save()); }
  editSt(patch) {
    if (this.state.startDate && this.state.view === "setup") this.setState(patch);
    else this.setSt(patch);
  }

  chaptersOf(books) { return books.reduce((s, n) => s + this.BOOKS.find(b => b[0] === n)[1], 0); }

  addSelection(name, books, freq) {
    const short = { "Old Testament": "OT", "New Testament": "NT" };
    const restName = (base) => "Rest of " + (short[base] || base);
    let newBooks = [...books];
    const sels = this.state.selections.map(s => ({ ...s }));
    sels.forEach(sel => {
      if (!sel.books.some(b => newBooks.includes(b))) return;
      if (this.chaptersOf(sel.books) > this.chaptersOf(newBooks)) {
        const base = sel.baseName || sel.name;
        sel.books = sel.books.filter(b => !newBooks.includes(b));
        sel.name = restName(base);
        sel.baseName = base;
      } else {
        newBooks = newBooks.filter(b => !sel.books.includes(b));
      }
    });
    if (!newBooks.length) return;
    const label = newBooks.length < books.length ? restName(name) : name;
    this.editSt({ selections: [...sels.filter(s => s.books.length), { name: label, baseName: name, books: newBooks, freq }] });
  }
  todayIndex(startDate) {
    const ms = Date.now() - new Date(startDate + "T00:00:00").getTime();
    return Math.max(0, Math.min(364, Math.floor(ms / 86400000)));
  }

  dayChapters(sel, i) {
    const seq = [];
    sel.books.forEach(n => {
      const c = this.BOOKS.find(b => b[0] === n)[1];
      for (let k = 1; k <= c; k++) seq.push({ book: n, ch: k });
    });
    const C = seq.length, total = Math.round(C * sel.freq);
    if (!total) return [];
    const a = Math.floor(i * total / 365), b = Math.floor((i + 1) * total / 365);
    const out = [];
    for (let k = a; k < b; k++) out.push(seq[k % C]);
    return out;
  }

  abbrev(book) {
    if (!this.ABBR) this.ABBR = { "Genesis":"Gn","Exodus":"Ex","Leviticus":"Lv","Numbers":"Nm","Deuteronomy":"Dt","Joshua":"Jos","Judges":"Jdg","Ruth":"Ru","1 Samuel":"1Sm","2 Samuel":"2Sm","1 Kings":"1Kg","2 Kings":"2Kg","1 Chronicles":"1Ch","2 Chronicles":"2Ch","Ezra":"Ezr","Nehemiah":"Neh","Esther":"Est","Job":"Job","Psalms":"Ps","Proverbs":"Prv","Ecclesiastes":"Ecc","Song of Songs":"Sng","Isaiah":"Isa","Jeremiah":"Jer","Lamentations":"Lam","Ezekiel":"Ezk","Daniel":"Dan","Hosea":"Hos","Joel":"Joel","Amos":"Am","Obadiah":"Ob","Jonah":"Jon","Micah":"Mic","Nahum":"Nah","Habakkuk":"Hab","Zephaniah":"Zep","Haggai":"Hag","Zechariah":"Zec","Malachi":"Mal","Matthew":"Mt","Mark":"Mk","Luke":"Lk","John":"Jn","Acts":"Ac","Romans":"Rom","1 Corinthians":"1Co","2 Corinthians":"2Co","Galatians":"Gal","Ephesians":"Eph","Philippians":"Php","Colossians":"Col","1 Thessalonians":"1Th","2 Thessalonians":"2Th","1 Timothy":"1Tm","2 Timothy":"2Tm","Titus":"Ti","Philemon":"Phm","Hebrews":"Heb","James":"Jas","1 Peter":"1Pt","2 Peter":"2Pt","1 John":"1Jn","2 John":"2Jn","3 John":"3Jn","Jude":"Jud","Revelation":"Rev" };
    return this.ABBR[book] || book;
  }

  formatChapters(chs, abbr) {
    if (!chs.length) return "";
    const parts = [];
    let start = chs[0], prev = chs[0];
    const flush = () => {
      const bk = abbr ? this.abbrev(start.book) : (start.book === "Psalms" ? "Psalm" : start.book);
      const one = this.BOOKS.find(b => b[0] === start.book)[1] === 1;
      if (one) parts.push(bk);
      else if (start.ch === prev.ch) parts.push(bk + " " + start.ch);
      else parts.push(bk + " " + start.ch + "–" + prev.ch);
    };
    for (let i = 1; i < chs.length; i++) {
      const c = chs[i];
      if (c.book === prev.book && c.ch === prev.ch + 1) { prev = c; continue; }
      flush(); start = c; prev = c;
    }
    flush();
    return parts.join("; ");
  }

  freqLabel(f) {
    if (f <= 0.5) return "every 2 years";
    if (f === 1) return "once a year";
    if (f === 12) return "once a month";
    if (f === 52) return "once a week";
    return f + "× a year";
  }

  osis(book) {
    if (!this.OSIS) this.OSIS = { "Genesis":"GEN","Exodus":"EXO","Leviticus":"LEV","Numbers":"NUM","Deuteronomy":"DEU","Joshua":"JOS","Judges":"JDG","Ruth":"RUT","1 Samuel":"1SA","2 Samuel":"2SA","1 Kings":"1KI","2 Kings":"2KI","1 Chronicles":"1CH","2 Chronicles":"2CH","Ezra":"EZR","Nehemiah":"NEH","Esther":"EST","Job":"JOB","Psalms":"PSA","Proverbs":"PRO","Ecclesiastes":"ECC","Song of Songs":"SNG","Isaiah":"ISA","Jeremiah":"JER","Lamentations":"LAM","Ezekiel":"EZK","Daniel":"DAN","Hosea":"HOS","Joel":"JOL","Amos":"AMO","Obadiah":"OBA","Jonah":"JON","Micah":"MIC","Nahum":"NAM","Habakkuk":"HAB","Zephaniah":"ZEP","Haggai":"HAG","Zechariah":"ZEC","Malachi":"MAL","Matthew":"MAT","Mark":"MRK","Luke":"LUK","John":"JHN","Acts":"ACT","Romans":"ROM","1 Corinthians":"1CO","2 Corinthians":"2CO","Galatians":"GAL","Ephesians":"EPH","Philippians":"PHP","Colossians":"COL","1 Thessalonians":"1TH","2 Thessalonians":"2TH","1 Timothy":"1TI","2 Timothy":"2TI","Titus":"TIT","Philemon":"PHM","Hebrews":"HEB","James":"JAS","1 Peter":"1PE","2 Peter":"2PE","1 John":"1JN","2 John":"2JN","3 John":"3JN","Jude":"JUD","Revelation":"REV" };
    return this.OSIS[book];
  }

  labelForCid(cid) {
    const [osis, ch] = String(cid).split(".");
    const book = Object.keys(this.OSIS || {}).find(k => this.OSIS[k] === osis) || osis;
    const single = (this.BOOKS.find(b => b[0] === book) || [])[1] === 1;
    const name = book === "Psalms" ? "Psalm" : book;
    return single ? name : name + " " + ch;
  }

  chapterIds(chs) {
    const seen = new Set(), out = [];
    (chs || []).forEach(c => { const id = this.osis(c.book) + "." + c.ch; if (!seen.has(id)) { seen.add(id); out.push(id); } });
    return out;
  }

  // ---------- day reader ----------
  // Every reading scheduled for a day, on one page: one block per plan stream,
  // each block holding its chapters as verse-tagged spans so audio can follow along.
  async openDay(dayIdx, focusBlock) {
    this.stopAudio();
    const st = this.state;
    const blocks = st.selections.map((sel, si) => {
      const chs = this.dayChapters(sel, dayIdx);
      if (!chs.length) return null;
      return { si, key: "d" + dayIdx + "s" + si, name: sel.name, ref: this.formatChapters(chs), chapterIds: this.chapterIds(chs), paras: [] };
    }).filter(Boolean);
    const prev = st.view === "read" ? (st.prevView || "today") : st.view;
    this.focusBlock = focusBlock;
    this.chapterVerses = {};
    this.setSt({ prevView: prev, view: "read", viewDay: dayIdx, day: { idx: dayIdx, blocks, loading: true, error: null, copyright: "" }, activeVid: null });
    this.loadDayText();
  }

  async loadDayText() {
    const day = this.state.day;
    if (!day) return;
    try {
      const blocks = day.blocks.map(b => ({ ...b, paras: [] }));
      let copyright = "";
      for (const b of blocks) {
        for (const cid of b.chapterIds) {
          const r = await fetch(this.API + "/bibles/" + this.TEXT_BIBLE + "/chapters/" + cid + "?content-type=json&include-verse-numbers=false&include-titles=false&include-chapter-numbers=false", this.apiHeaders());
          const j = await r.json();
          if (!j.data) continue;
          const parsed = this.parseContent(j.data.content);
          // keep verse order + length per chapter so audio can follow along even
          // when the audio edition publishes no verse timecodes
          const vids = [], lens = {};
          parsed.forEach(p => p.spans.forEach(sp => {
            if (!sp.vid) return;
            if (lens[sp.vid] === undefined) { vids.push(sp.vid); lens[sp.vid] = 0; }
            lens[sp.vid] += sp.t.length;
          }));
          this.chapterVerses[cid] = { vids, lens };
          b.paras = b.paras.concat(parsed);
          copyright = j.data.copyright || copyright;
        }
      }
      if (this.state.view !== "read") return;
      this.setState({ day: { ...day, blocks, loading: false, copyright: (copyright || "").trim() } }, () => {
        if (this.focusBlock != null) { this.scrollToEl('[data-block="' + this.focusBlock + '"]', 0.12); this.focusBlock = null; }
      });
    } catch (e) {
      this.setState({ day: { ...day, loading: false, error: "Couldn't reach the Bible API. Check your connection and try again." } });
    }
  }

  // api.bible JSON content → paragraphs of verse-tagged text runs
  parseContent(content) {
    const paras = [];
    const collect = (items, out) => {
      (items || []).forEach(it => {
        if (it.type === "text") { if (it.text) out.push({ vid: (it.attrs && it.attrs.verseId) || "", t: it.text }); }
        else if (it.items) collect(it.items, out);
      });
    };
    (content || []).forEach(node => {
      const out = [];
      collect(node.items, out);
      if (out.length) paras.push({ spans: out });
    });
    return paras;
  }

  // The page may be scrolled by `window` OR by a scrollable ancestor (the app runs
  // embedded), so find whichever actually moves before tweening it.
  scrollParentOf(el) {
    let n = el.parentElement;
    while (n) {
      const o = getComputedStyle(n).overflowY;
      if ((o === "auto" || o === "scroll" || o === "overlay") && n.scrollHeight > n.clientHeight + 1) return n;
      n = n.parentElement;
    }
    const de = document.scrollingElement || document.documentElement;
    return de.scrollHeight > de.clientHeight + 1 ? de : null;
  }

  scrollToEl(sel, ratio) {
    const el = document.querySelector(sel);
    if (!el) return;
    const box = this.scrollParentOf(el);
    if (!box) return;
    const isDoc = box === document.scrollingElement || box === document.documentElement || box === document.body;
    const viewH = isDoc ? window.innerHeight : box.clientHeight;
    const cur = isDoc ? (window.scrollY || box.scrollTop) : box.scrollTop;
    const r = el.getBoundingClientRect();
    const boxTop = isDoc ? 0 : box.getBoundingClientRect().top;
    // centre the verse itself; a ratio pins its top that far down instead
    const offset = ratio == null ? (viewH - r.height) / 2 : viewH * ratio;
    const to = Math.max(0, cur + (r.top - boxTop) - offset);
    this.tweenScroll(box, isDoc, to);
  }

  tweenScroll(box, isDoc, to) {
    const from = isDoc ? (window.scrollY || box.scrollTop) : box.scrollTop;
    if (Math.abs(to - from) < 2) return;
    const set = (y) => { if (isDoc) window.scrollTo(0, y); box.scrollTop = y; };
    if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) { set(to); return; }
    if (this.scrollRaf) cancelAnimationFrame(this.scrollRaf);
    const ease = (t) => 1 - Math.pow(1 - t, 3);
    const dur = 420, t0 = performance.now();
    const step = (now) => {
      const p = Math.min(1, (now - t0) / dur);
      set(from + (to - from) * ease(p));
      if (p < 1) this.scrollRaf = requestAnimationFrame(step); else this.scrollRaf = null;
    };
    this.scrollRaf = requestAnimationFrame(step);
  }

  componentDidUpdate(prevProps, prevState) {
    if (this.state.activeVid && this.state.activeVid !== prevState.activeVid && this.state.follow !== false) {
      requestAnimationFrame(() => this.scrollToEl('[data-verse="' + this.state.activeVid + '"]'));
    }
  }

  // ---------- audio ----------
  async ensureAudioBible() {
    if (this.audioReady) return this.audioBibleId;
    try {
      const r = await fetch(this.API + "/audio-bibles?language=eng&include-full-details=true", this.apiHeaders());
      const j = await r.json();
      const list = j.data || [];
      const pick = list.find(b => /niv/i.test(b.abbreviation || "") || /niv/i.test(b.abbreviationLocal || "")) || list[0];
      this.audioBibleId = pick ? pick.id : null;
      this.audioBibleName = pick ? (pick.abbreviationLocal || pick.abbreviation || pick.name || "") : "";
      this.audioBibleFull = pick ? (pick.name || "") : "";
      this.audioBibleCopyright = pick ? (pick.copyright || "") : "";
      if (this.audioBibleId) {
        const rb = await fetch(this.API + "/audio-bibles/" + this.audioBibleId + "/books?include-chapters=true", this.apiHeaders());
        const jb = await rb.json();
        const set = new Set();
        (jb.data || []).forEach(bk => (bk.chapters || []).forEach(c => set.add(c.id)));
        this.audioChapters = set;
      }
    } catch (e) { this.audioBibleId = null; }
    this.audioReady = true;
    return this.audioBibleId;
  }

  componentDidMount() { this.ensureAudioBible().then(() => this.forceUpdate()); }

  audioIdsFor(ids) {
    if (!this.audioChapters) return ids || [];
    return (ids || []).filter(id => this.audioChapters.has(id));
  }

  dayAudioQueue() {
    const day = this.state.day;
    if (!day) return [];
    const q = [];
    day.blocks.forEach((b, bi) => this.audioIdsFor(b.chapterIds).forEach(cid => q.push({ cid, bi })));
    return q;
  }

  async startAudio() {
    if (!this.state.day) return;
    this.setState({ audio: { state: "loading", i: 0, n: 0 } });
    const id = await this.ensureAudioBible();
    if (!id) return this.setState({ audio: { state: "error", error: "No audio Bible is licensed on this key yet." } });
    const q = this.dayAudioQueue();
    if (!q.length) return this.setState({ audio: { state: "none", i: 0, n: 0 } });
    this.audioQueue = q;
    this.playAt(0);
  }

  tcSeconds(v) {
    if (v == null) return 0;
    if (typeof v === "number") return v;
    const s = String(v);
    if (s.indexOf(":") > -1) return s.split(":").reduce((a, p) => a * 60 + parseFloat(p), 0);
    return parseFloat(s) || 0;
  }

  // presigned resourceUrl expires and shouldn't be streamed directly — download, then play
  async playAt(i) {
    const q = this.audioQueue || [];
    if (i >= q.length) {
      if (q.length) this.markBlockRead(q[q.length - 1].bi);
      return this.setState({ audio: { state: "done", i: q.length - 1, n: q.length }, activeVid: null });
    }
    this.audioIdx = i;
    this.setState({ audio: { state: "loading", i, n: q.length } });
    try {
      const r = await fetch(this.API + "/audio-bibles/" + this.audioBibleId + "/chapters/" + q[i].cid, this.apiHeaders());
      const j = await r.json();
      const url = j.data && j.data.resourceUrl;
      if (!url) return this.playAt(i + 1);
      if (j.data.copyright) this.audioBibleCopyright = j.data.copyright;
      this.timecodes = (j.data.timecodes || []).map(t => ({ a: this.tcSeconds(t.start), b: this.tcSeconds(t.end), vid: t.verseId }));
      this.estimated = false;
      this.currentCid = q[i].cid;
      this.syncOffset = 0;
      const blob = await (await fetch(url)).blob();
      if (this.audioObjectUrl) URL.revokeObjectURL(this.audioObjectUrl);
      this.audioObjectUrl = URL.createObjectURL(blob);
      if (!this.audioEl) {
        this.audioEl = new Audio();
        this.audioEl.onended = () => {
          const cur = (this.audioQueue || [])[this.audioIdx];
          const nxt = (this.audioQueue || [])[this.audioIdx + 1];
          if (cur && (!nxt || nxt.bi !== cur.bi)) this.markBlockRead(cur.bi);
          this.playAt(this.audioIdx + 1);
        };
        this.audioEl.onerror = () => this.playAt(this.audioIdx + 1);
        this.audioEl.onloadedmetadata = () => this.buildSynthTimecodes();
        this.audioEl.ontimeupdate = () => {
          const tcs = this.timecodes;
          if (!tcs || !tcs.length) return;
          // estimated timing runs behind the narrator (chapter titles, breaths), so nudge
          // the lookup forward — LEAD covers the average, syncOffset is the user's correction
          const t = this.audioEl.currentTime + this.LEAD + (this.syncOffset || 0);
          const hit = tcs.find(x => t >= x.a && t < x.b) || (t >= tcs[tcs.length - 1].b ? tcs[tcs.length - 1] : null);
          if (hit && hit.vid && hit.vid !== this.state.activeVid) this.setState({ activeVid: hit.vid });
        };
      }
      this.audioEl.src = this.audioObjectUrl;
      this.applySpeed(this.state.speed || 1);
      await this.audioEl.play();
      this.setState({ audio: { state: "playing", i, n: q.length } });
    } catch (e) {
      this.setState({ audio: { state: "error", error: "Couldn't play this chapter — press play to try again." } });
    }
  }

  // No licensed audio edition publishes verse timecodes yet, so when they're absent
  // we spread the chapter's duration across its verses by character count. Good enough
  // to keep the page moving with the reading; swapped out automatically if real
  // timecodes ever arrive in the response.
  // rough spoken length of "Book, chapter N" — long book names take longer to say
  introSeconds(cid) {
    if (!cid) return 3.4;
    const osis = String(cid).split(".")[0];
    const book = Object.keys(this.OSIS || {}).find(k => this.OSIS[k] === osis) || "";
    const words = (book.split(/\s+/).filter(Boolean).length || 1) + 2; // + "chapter" + number
    return 1.6 + words * 0.62;
  }

  buildSynthTimecodes() {
    if (this.timecodes && this.timecodes.length) return;
    const cv = this.chapterVerses && this.chapterVerses[this.currentCid];
    const D = this.audioEl && this.audioEl.duration;
    if (!cv || !cv.vids.length || !D || !isFinite(D)) return;
    const total = cv.vids.reduce((n, v) => n + (cv.lens[v] || 1), 0);
    // The narrator announces the book and chapter before verse 1 ("The Gospel
    // according to Matthew, chapter one"). Hold verse 1 through that intro, sized
    // from the spoken reference itself (~2.6 syllables/sec) and clamped so a short
    // chapter can't spend most of its runtime on the heading.
    let t = Math.min(this.introSeconds(this.currentCid), D * 0.14);
    this.timecodes = cv.vids.map(v => {
      const dur = D * (cv.lens[v] || 1) / total;
      const seg = { a: t, b: t + dur, vid: v };
      t += dur;
      return seg;
    });
    this.estimated = true;
  }

  // tap the verse the narrator is actually on — the estimate re-anchors there
  resyncTo = (e) => {
    const vid = e.currentTarget && e.currentTarget.getAttribute("data-verse");
    const tcs = this.timecodes;
    if (!vid || !tcs || !this.audioEl || !this.audioEl.src) return;
    const seg = tcs.find(x => x.vid === vid);
    if (!seg) return;
    this.syncOffset = seg.a - (this.audioEl.currentTime + this.LEAD);
    this.setState({ activeVid: vid });
  };

  markBlockRead(bi) {
    const day = this.state.day;
    if (!day || !day.blocks[bi]) return;
    const key = day.blocks[bi].key;
    if (this.state.checks[key]) return;
    this.setSt({ checks: { ...this.state.checks, [key]: true } });
  }

  applySpeed(rate) {
    if (!this.audioEl) return;
    // keep the narrator's pitch natural at speed
    this.audioEl.preservesPitch = true;
    this.audioEl.mozPreservesPitch = true;
    this.audioEl.webkitPreservesPitch = true;
    this.audioEl.playbackRate = rate || 1;
  }

  pauseAudio() { if (this.audioEl) this.audioEl.pause(); this.setState(s => ({ audio: { ...(s.audio || {}), state: "paused" } })); }
  resumeAudio() { if (this.audioEl) this.audioEl.play(); this.setState(s => ({ audio: { ...(s.audio || {}), state: "playing" } })); }
  stopAudio() {
    if (this.audioEl) { this.audioEl.pause(); this.audioEl.removeAttribute("src"); }
    if (this.audioObjectUrl) { URL.revokeObjectURL(this.audioObjectUrl); this.audioObjectUrl = null; }
    this.audioQueue = null;
    this.timecodes = null;
    this.setState({ audio: null, activeVid: null, queueOpen: false });
  }
  componentWillUnmount() {
    if (this.audioEl) this.audioEl.pause();
    if (this.audioObjectUrl) URL.revokeObjectURL(this.audioObjectUrl);
  }

  streak(todayIdx) {
    const dayDone = (i) => {
      let sched = 0, chk = 0;
      this.state.selections.forEach((sel, si) => { if (this.dayChapters(sel, i).length) { sched++; if (this.state.checks["d" + i + "s" + si]) chk++; } });
      return sched === 0 ? null : chk === sched;
    };
    let n = 0, i = todayIdx;
    if (dayDone(i) === false) i--;
    for (; i >= 0; i--) { const d = dayDone(i); if (d === false) break; if (d === true) n++; }
    return n;
  }

  dateOf(i) {
    const d = new Date(this.state.startDate + "T00:00:00");
    d.setDate(d.getDate() + i);
    return d;
  }
}

if (typeof module !== "undefined") module.exports = { Component, DCLogic };
