301 lines
8.8 KiB
Vue
301 lines
8.8 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, nextTick, computed, onMounted } from 'vue'
|
|
import Panel from '@framework/components/Panel.vue'
|
|
import { useStore } from '../store'
|
|
|
|
const props = withDefaults(defineProps<{ allowEdit?: boolean }>(), { allowEdit: true })
|
|
|
|
const { state, activeSegmentIndex, seek } = useStore()
|
|
|
|
// View preferences (persisted). When edit is disallowed (e.g. embedded review),
|
|
// the transcript is read-only regardless of the saved preference.
|
|
type ViewMode = 'edit' | 'read'
|
|
const viewMode = ref<ViewMode>((localStorage.getItem('meetus.viewMode') as ViewMode) || 'edit')
|
|
const effectiveMode = computed<ViewMode>(() => (props.allowEdit ? viewMode.value : 'read'))
|
|
const compact = ref(localStorage.getItem('meetus.compact') === '1')
|
|
const showTimes = ref(localStorage.getItem('meetus.readTimes') !== '0')
|
|
watch(viewMode, (v) => localStorage.setItem('meetus.viewMode', v))
|
|
watch(compact, (v) => localStorage.setItem('meetus.compact', v ? '1' : '0'))
|
|
watch(showTimes, (v) => localStorage.setItem('meetus.readTimes', v ? '1' : '0'))
|
|
|
|
// One element per segment index (whichever mode is rendered), for auto-scroll.
|
|
const segEls = ref<Record<number, HTMLElement>>({})
|
|
function setSeg(i: number, el: Element | null) {
|
|
if (el) segEls.value[i] = el as HTMLElement
|
|
}
|
|
|
|
function fmt(seconds: number): string {
|
|
const m = Math.floor(seconds / 60)
|
|
const s = Math.floor(seconds % 60)
|
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
}
|
|
|
|
// Auto-grow textareas. They must be re-fit after the transcript loads (initial
|
|
// mount can measure before layout/fonts settle), and after mode/density changes.
|
|
const areaEls = ref<Record<number, HTMLTextAreaElement>>({})
|
|
function fit(el: HTMLTextAreaElement) {
|
|
el.style.height = 'auto'
|
|
el.style.height = `${el.scrollHeight}px`
|
|
}
|
|
function setArea(i: number, el: Element | null) {
|
|
if (!el) return
|
|
areaEls.value[i] = el as HTMLTextAreaElement
|
|
fit(el as HTMLTextAreaElement)
|
|
}
|
|
function autoResize(e: Event) { fit(e.target as HTMLTextAreaElement) }
|
|
function resizeAll() {
|
|
for (const el of Object.values(areaEls.value)) {
|
|
if (el.isConnected) fit(el)
|
|
}
|
|
}
|
|
|
|
// Re-fit whenever the segment set is (re)loaded or the layout mode changes.
|
|
watch(() => [state.segments, viewMode.value, compact.value], () => {
|
|
nextTick(resizeAll)
|
|
})
|
|
onMounted(() => {
|
|
nextTick(resizeAll)
|
|
// Fonts can load after first paint and change wrapping/height.
|
|
;(document as Document & { fonts?: FontFaceSet }).fonts?.ready.then(resizeAll)
|
|
})
|
|
|
|
// Read view: merge consecutive same-speaker segments into flowing turns.
|
|
const readingTurns = computed(() => {
|
|
const turns: Array<{ speaker: string; start: number; items: Array<{ i: number; start: number; text: string }> }> = []
|
|
let cur: (typeof turns)[number] | null = null
|
|
state.segments.forEach((s, i) => {
|
|
const sp = s.speaker || 'SPEAKER'
|
|
if (!cur || cur.speaker !== sp) {
|
|
cur = { speaker: sp, start: s.start, items: [] }
|
|
turns.push(cur)
|
|
}
|
|
cur.items.push({ i, start: s.start, text: s.text })
|
|
})
|
|
return turns
|
|
})
|
|
|
|
// Keep the active segment in view during playback (not while editing).
|
|
watch(activeSegmentIndex, async (i) => {
|
|
if (i < 0) return
|
|
const tag = document.activeElement?.tagName
|
|
if (tag === 'TEXTAREA' || tag === 'INPUT') return
|
|
await nextTick()
|
|
segEls.value[i]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Panel title="Transcript">
|
|
<template #actions>
|
|
<div v-if="allowEdit" class="seg-control">
|
|
<button :class="{ on: effectiveMode === 'edit' }" @click="viewMode = 'edit'">Edit</button>
|
|
<button :class="{ on: effectiveMode === 'read' }" @click="viewMode = 'read'">Read</button>
|
|
</div>
|
|
<button v-if="allowEdit && effectiveMode === 'edit'" :class="{ on: compact }" @click="compact = !compact">Compact</button>
|
|
<button v-else :class="{ on: showTimes }" @click="showTimes = !showTimes">Times</button>
|
|
<span class="count">{{ state.segments.length }} segments</span>
|
|
</template>
|
|
|
|
<!-- EDIT: per-segment blocks (comfortable or compact density) -->
|
|
<div v-if="effectiveMode === 'edit'" class="seg-list" :class="{ compact }">
|
|
<p v-if="!state.segments.length" class="empty">No transcript for this run.</p>
|
|
<div
|
|
v-for="(seg, i) in state.segments"
|
|
:key="i"
|
|
:ref="(el) => setSeg(i, el as Element | null)"
|
|
class="seg"
|
|
:class="{ active: i === activeSegmentIndex }"
|
|
>
|
|
<div class="seg-head">
|
|
<button class="ts" title="Seek here" @click="seek(seg.start)">{{ fmt(seg.start) }}</button>
|
|
<input v-model="seg.speaker" class="speaker" spellcheck="false" placeholder="SPEAKER" />
|
|
</div>
|
|
<textarea
|
|
v-model="seg.text"
|
|
class="text"
|
|
rows="1"
|
|
spellcheck="false"
|
|
:ref="(el) => setArea(i, el as Element | null)"
|
|
@input="autoResize"
|
|
@focus="seek(seg.start)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- READ: continuous, speaker-grouped flowing text (view only) -->
|
|
<div v-else class="read">
|
|
<p v-if="!state.segments.length" class="empty">No transcript for this run.</p>
|
|
<div v-for="(turn, ti) in readingTurns" :key="ti" class="turn">
|
|
<div class="turn-head">
|
|
<button class="ts" title="Seek here" @click="seek(turn.start)">{{ fmt(turn.start) }}</button>
|
|
<span class="speaker-lbl">{{ turn.speaker }}</span>
|
|
</div>
|
|
<p class="turn-body">
|
|
<template v-for="it in turn.items" :key="it.i">
|
|
<button
|
|
v-if="showTimes"
|
|
class="inline-ts"
|
|
title="Seek here"
|
|
@click="seek(it.start)"
|
|
>{{ fmt(it.start) }}</button
|
|
><span
|
|
:ref="(el) => setSeg(it.i, el as Element | null)"
|
|
class="rseg"
|
|
:class="{ active: it.i === activeSegmentIndex }"
|
|
@click="seek(it.start)"
|
|
>{{ it.text.trim() + ' ' }}</span>
|
|
</template>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ---- Edit view ---- */
|
|
.seg-list {
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
padding: var(--space-2);
|
|
}
|
|
.empty {
|
|
color: var(--text-secondary);
|
|
padding: var(--space-4);
|
|
}
|
|
.seg {
|
|
padding: var(--space-2);
|
|
border-radius: var(--panel-radius);
|
|
border: 1px solid transparent;
|
|
margin-bottom: var(--space-1);
|
|
}
|
|
.seg.active {
|
|
background: var(--surface-2);
|
|
border-color: var(--status-processing);
|
|
}
|
|
.seg-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
margin-bottom: var(--space-1);
|
|
}
|
|
.ts {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--font-size-sm);
|
|
color: var(--status-processing);
|
|
background: var(--surface-0);
|
|
padding: 1px var(--space-2);
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
.speaker {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--font-size-sm);
|
|
color: var(--text-secondary);
|
|
padding: 1px var(--space-2);
|
|
width: 130px;
|
|
}
|
|
.text {
|
|
width: 100%;
|
|
resize: none;
|
|
overflow: hidden;
|
|
line-height: 1.45;
|
|
padding: var(--space-2);
|
|
background: var(--surface-0);
|
|
}
|
|
.count {
|
|
color: var(--text-secondary);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
/* Compact density: time + speaker inline on the same row as the text. */
|
|
.seg-list.compact .seg {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: var(--space-2);
|
|
padding: 1px var(--space-1);
|
|
margin-bottom: 0;
|
|
border-radius: 0;
|
|
}
|
|
.seg-list.compact .seg-head {
|
|
margin-bottom: 0;
|
|
flex-shrink: 0;
|
|
padding-top: 2px;
|
|
}
|
|
.seg-list.compact .speaker {
|
|
width: 96px;
|
|
}
|
|
.seg-list.compact .text {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: 1px var(--space-2);
|
|
background: transparent;
|
|
border-color: transparent;
|
|
line-height: 1.35;
|
|
}
|
|
.seg-list.compact .seg.active {
|
|
background: var(--surface-2);
|
|
}
|
|
.seg-list.compact .seg.active .text {
|
|
background: transparent;
|
|
}
|
|
|
|
/* ---- Read view ---- */
|
|
.read {
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
padding: var(--space-3) var(--space-4);
|
|
line-height: 1.55;
|
|
}
|
|
.turn {
|
|
margin-bottom: var(--space-3);
|
|
}
|
|
.turn-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
margin-bottom: 2px;
|
|
}
|
|
.speaker-lbl {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--font-size-sm);
|
|
color: var(--text-secondary);
|
|
}
|
|
.turn-body {
|
|
margin: 0;
|
|
}
|
|
.rseg {
|
|
cursor: pointer;
|
|
border-radius: 3px;
|
|
}
|
|
.rseg:hover {
|
|
background: var(--surface-2);
|
|
}
|
|
.rseg.active {
|
|
background: var(--surface-2);
|
|
box-shadow: 0 0 0 1px var(--status-processing);
|
|
}
|
|
.inline-ts {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
background: transparent;
|
|
border: none;
|
|
padding: 0 3px 0 0;
|
|
vertical-align: baseline;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
.inline-ts:hover {
|
|
color: var(--status-processing);
|
|
}
|
|
.seg-control {
|
|
display: flex;
|
|
gap: 2px;
|
|
}
|
|
.seg-control button.on,
|
|
button.on {
|
|
background: var(--surface-3);
|
|
border-color: var(--status-processing);
|
|
}
|
|
</style>
|