File size: 5,183 Bytes
b1a388c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import { computed, nextTick, ref } from 'vue'

// ── helpers ────────────────────────────────────────────────────────────────

/**
 * Normalise locate_info into an array of paragraph_range objects.
 * Handles both:
 *   { paragraph_ranges: [...] }
 *   { "0": { paragraph_ranges: [...] }, "1": { paragraph_ranges: [...] } }
 */
function normaliseRanges(locateInfo) {
  if (!locateInfo) return []
  if (Array.isArray(locateInfo.paragraph_ranges)) return locateInfo.paragraph_ranges
  // keyed form (MULTIPLE_CHOICE_MANY etc.)
  return Object.values(locateInfo).flatMap(v =>
    Array.isArray(v?.paragraph_ranges) ? v.paragraph_ranges : []
  )
}

/**
 * Given a single paragraph_range and the flat paragraphs array,
 * return the child (sentence) IDs that fall within the range.
 *
 * Data contract:
 *  - range.start / range.end have { paragraph: 1-based, sentence?: 1-based }
 *  - paragraphs[i].paragraph === i+1  (built by buildParagraphsFromVocabs)
 *  - paragraphs[i].children[j] is sentence j+1 of that paragraph
 */
function idsFromRange(paragraphs, range) {
  const { start, end } = range || {}
  if (!start || !end) return []

  const pStart = start.paragraph
  const pEnd   = end.paragraph
  const sStart = start.sentence ?? 1
  const sEnd   = end.sentence   ?? Infinity

  const ids = []
  for (const p of paragraphs) {
    const pIdx = p.paragraph
    if (pIdx < pStart || pIdx > pEnd) continue

    const children = p.children || []
    children.forEach((c, i) => {
      const si = i + 1 // 1-based sentence index within this paragraph
      if (pIdx === pStart && pIdx === pEnd) {
        if (si >= sStart && si <= sEnd) ids.push(c.id)
      } else if (pIdx === pStart) {
        if (si >= sStart) ids.push(c.id)
      } else if (pIdx === pEnd) {
        if (si <= sEnd) ids.push(c.id)
      } else {
        ids.push(c.id) // middle paragraphs: all sentences
      }
    })
  }
  return ids
}

// ── composable ─────────────────────────────────────────────────────────────

/**
 * @param {import('vue').Ref<Array>} paragraphsRef  – reactive ref to paragraph array
 *        (output of buildParagraphsFromVocabs, 1-based paragraph indices)
 * @param {import('vue').Ref<number>} currentTimeRef – reactive audio currentTime
 *
 * @returns {{
 *   segments: ComputedRef,
 *   activeSegId: ComputedRef<string|null>,
 *   highlightedIds: ComputedRef<Set>,
 *   activateLocateInfo(locateInfo: object): void,
 *   clearForced(): void,
 *   scrollToSegId: Ref<string|null>,   // sentinel: changes → caller should scroll
 * }}
 */
export function useTranscript(paragraphsRef, currentTimeRef) {
  // ── flat sentence list ───────────────────────────────────────────
  const segments = computed(() => {
    const out = []
    for (const p of (paragraphsRef.value || [])) {
      for (const c of (p.children || [])) {
        if (!Number.isFinite(c.from) || !Number.isFinite(c.to)) continue
        out.push({ id: c.id, from: c.from, to: c.to, speaker: c.speaker, text: c.text })
      }
    }
    return out
  })

  // ── time-based active segment ────────────────────────────────────
  const activeSegId = computed(() => {
    const t = currentTimeRef.value || 0
    return segments.value.find(s => t >= s.from && t <= s.to)?.id ?? null
  })

  // ── forced highlight (from question play button) ─────────────────
  const _forcedIds = ref(/** @type {Set<string>} */ (new Set()))
  let _clearTimer  = null

  /**
   * Parse locate_info → find sentence IDs → force-highlight them for 5 s.
   * After 5 s they are cleared automatically.
   */
  function activateLocateInfo(locateInfo) {
    const paragraphs = paragraphsRef.value || []
    const ranges = normaliseRanges(locateInfo)
    const ids = ranges.flatMap(r => idsFromRange(paragraphs, r))
    _forcedIds.value = new Set(ids)
    clearTimeout(_clearTimer)
    if (ids.length) {
      _clearTimer = setTimeout(() => { _forcedIds.value = new Set() }, 5000)
    }
  }

  function clearForced() {
    clearTimeout(_clearTimer)
    _forcedIds.value = new Set()
  }

  // ── merged: forced takes priority, else time-based ───────────────
  const highlightedIds = computed(() => {
    if (_forcedIds.value.size > 0) return _forcedIds.value
    return activeSegId.value ? new Set([activeSegId.value]) : new Set()
  })

  /**
   * When the caller should scroll the transcript, this ref changes to the
   * target segment ID.  Consumers can watch it.
   */
  const scrollToSegId = computed(() => {
    if (_forcedIds.value.size > 0) return [..._forcedIds.value][0]
    return activeSegId.value
  })

  return {
    segments,
    activeSegId,
    highlightedIds,
    scrollToSegId,
    activateLocateInfo,
    clearForced,
  }
}