Spaces:
Paused
Paused
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,
}
}
|