linguaielts-api / fronted /src /components /shadowing /ShadowingStudio.vue
AnhviNguyen
Forecast, dashboard, pronunciation, nginx fixes (exclude large ML weights)
3092643
Raw
History Blame Contribute Delete
13.9 kB
<template>
<div class="shadowing-studio flex h-screen min-h-0 flex-col">
<header class="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--border)] bg-[var(--bg-surface)] px-4 py-3">
<div class="flex flex-wrap gap-2">
<button
v-for="t in tabs"
:key="t.id"
type="button"
class="sh-tab"
:class="{ 'is-active': activeTab === t.id }"
@click="activeTab = t.id"
>
{{ t.label }}
</button>
</div>
<div class="ml-auto flex items-center gap-2">
<button type="button" class="sh-btn gap-2 text-[12px]" @click="showVideo = !showVideo">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path v-if="showVideo" d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line v-if="showVideo" x1="1" y1="1" x2="23" y2="23"/>
<template v-else>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</template>
</svg>
{{ showVideo ? 'Ẩn video' : 'Hiện video' }}
</button>
<button type="button" class="sh-btn text-[12px]" @click="$emit('back')">Quay lại</button>
</div>
</header>
<div class="sh-studio-grid min-h-0 w-full max-w-full flex-1 overflow-hidden">
<!-- Cột trái — video + điều khiển; chỉ cột này cuộn dọc -->
<div class="sh-left-col min-h-0 border-r border-[var(--border)] bg-[var(--bg-surface)]">
<div class="sh-left-col-scroll">
<header class="sh-video-meta">
<button
type="button"
class="flex items-center gap-1 text-left text-[12px] font-semibold text-[var(--ink2)] hover:text-[var(--ink)]"
@click="$emit('back')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
Quay lại · {{ videoData.level }}
</button>
<h1 class="sh-video-title">{{ videoData.title }}</h1>
</header>
<div v-show="showVideo" class="sh-video-shell">
<div ref="playerHost" class="sh-video-player" />
</div>
<div class="sh-left-controls">
<template v-if="activeTab === 'shadowing'">
<p class="sh-panel-title">Điều khiển</p>
<ShadowingPlaybackControls
:playing="playing"
:rate="playbackRate"
:compact="false"
@prev="prevSegment"
@replay="replaySegment"
@next="nextSegment"
@toggle-play="onTogglePlay"
@update:rate="playbackRate = $event"
/>
<label class="flex cursor-pointer items-center gap-2 text-[12px] font-medium text-[var(--ink2)]">
<input v-model="showTranslation" type="checkbox" class="rounded border-[var(--border)] text-[var(--spotify-green)]" />
Bản dịch
</label>
</template>
<template v-else-if="activeTab === 'dictation'">
<p class="sh-panel-title">Điều khiển</p>
<div class="grid grid-cols-2 gap-2">
<button type="button" class="sh-btn sh-btn-primary sh-btn-block" @click="onTogglePlay">
{{ playing ? 'Tạm dừng' : 'Phát' }}
</button>
<button type="button" class="sh-btn sh-btn-block" @click="onDictationReplay">Phát lại</button>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="sh-card p-3">
<div class="text-[10px] font-bold uppercase text-gray-500">Lỗi sai</div>
<div class="text-2xl font-bold text-rose-600">{{ dictationErrors }}</div>
</div>
<div class="sh-card p-3">
<div class="text-[10px] font-bold uppercase text-gray-500">Lượt phát lại</div>
<div class="text-2xl font-bold text-black">{{ dictationReplays }}</div>
</div>
</div>
<div class="sh-card border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-900">
<span class="font-bold">Lý do:</span> Nghe và gõ lại câu. Nhấn biểu tượng mắt từng từ ở giữa hoặc bản chép để gợi ý.
</div>
<div class="sh-card p-3">
<div class="text-[10px] font-bold uppercase text-gray-500">Điểm lần trước</div>
<div class="text-xl font-bold text-[var(--spotify-green)]">{{ dictationLastScore ?? '—' }}</div>
</div>
<ShadowingPlaybackControls
:playing="playing"
:rate="playbackRate"
:compact="true"
@prev="prevSegment"
@replay="onDictationReplay"
@next="nextSegment"
@toggle-play="onTogglePlay"
@update:rate="playbackRate = $event"
/>
</template>
<template v-else>
<p class="sh-panel-title">Ghi âm phát âm</p>
<button type="button" class="sh-btn sh-btn-primary sh-btn-block gap-2 py-3" :disabled="pronunciationRef?.checking" @click="pronunciationRef?.toggleRecord?.()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
</svg>
{{ pronunciationRef?.checking ? 'Đang phân tích...' : pronunciationRef?.listening ? 'Đang ghi...' : 'Ghi âm' }}
</button>
<button
type="button"
class="sh-btn sh-btn-block"
:disabled="!pronunciationRef?.lastBlobUrl"
@click="pronunciationRef?.playRecording?.()"
>
Phát lại ghi âm
</button>
<div class="sh-card p-3">
<div class="mb-1 flex items-center gap-1 text-[10px] font-bold uppercase text-gray-500">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
Điểm phát âm
</div>
<div class="text-3xl font-bold text-black">{{ pronunciationScoreDisplay }}</div>
</div>
<div class="sh-card border-amber-200 bg-amber-50 p-3 text-[11px] italic text-amber-900">
<span class="font-bold not-italic">Mẹo:</span> {{ pronunciationTipDisplay }}
</div>
<ShadowingPlaybackControls
:playing="playing"
:rate="playbackRate"
:compact="true"
:show-speed="false"
@prev="prevSegment"
@replay="replaySegment"
@next="nextSegment"
@toggle-play="onTogglePlay"
@update:rate="playbackRate = $event"
/>
</template>
</div>
</div>
</div>
<!-- Cột giữa — card (2) -->
<div class="sh-middle-panel flex min-h-0 min-w-0 flex-col border-r border-[var(--border-button)]">
<ShadowingFocusCard
v-if="activeTab === 'shadowing'"
:segment="currentSegment"
:show-translation="showTranslation"
:source-quiz-id="videoData.video_id"
/>
<DictationTab
v-else-if="activeTab === 'dictation'"
ref="dictationRef"
:segment="currentSegment"
:segment-index="currentIndex"
:revealed-word-keys="dictationRevealedKeys"
@scored="onDictationScored"
@next="nextSegment"
@replay="onDictationReplay"
@reveal-word="onRevealWord"
@reveal-all="onRevealAllWords"
/>
<PronunciationTab
v-else
ref="pronunciationRef"
:segment="currentSegment"
:show-translation="showTranslation"
:segment-index="currentIndex"
:source-quiz-id="videoData.video_id"
@scored="onPronunciationScored"
/>
</div>
<!-- Cột phải — transcript (3) -->
<div class="flex min-h-0 min-w-0 flex-col overflow-hidden max-lg:min-h-[40vh]">
<ShadowingTranscriptPanel
v-model:show-translation="showTranslation"
:segments="segments"
:active-index="currentIndex"
:flagged-ids="flaggedIds"
:dictation-mode="activeTab === 'dictation'"
:revealed-word-keys="dictationRevealedKeys"
:vocab-enabled="activeTab !== 'dictation'"
:source-quiz-id="videoData.video_id"
@seek="onTranscriptSeek"
@toggle-flag="toggleFlag"
@reveal-word="onRevealWord"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, toRef, nextTick } from 'vue'
import { useShadowingSession } from '@/composables/useShadowingSession.js'
import { touchShadowingHistory } from '@/services/shadowingService.js'
import { useBadgeCelebrationStore } from '@/stores/badgeCelebration.js'
import { loadProgress, saveProgress, saveVideoMeta } from '@/utils/shadowingProgress.js'
import ShadowingFocusCard from '@/components/shadowing/ShadowingFocusCard.vue'
import DictationTab from '@/components/shadowing/tabs/DictationTab.vue'
import PronunciationTab from '@/components/shadowing/tabs/PronunciationTab.vue'
import ShadowingTranscriptPanel from '@/components/shadowing/ShadowingTranscriptPanel.vue'
import ShadowingPlaybackControls from '@/components/shadowing/ShadowingPlaybackControls.vue'
const props = defineProps({
videoData: { type: Object, required: true },
})
defineEmits(['back'])
const videoDataRef = toRef(props, 'videoData')
const {
playing,
mountPlayer: mountYtPlayer,
destroyPlayer,
currentIndex,
playbackRate,
showVideo,
showTranslation,
flaggedIds,
segments,
currentSegment,
loadSaved,
playSegment,
nextSegment,
prevSegment,
replaySegment,
toggleFlag,
onTimeUpdate,
pause,
play,
} = useShadowingSession(videoDataRef)
const activeTab = ref('shadowing')
const playerHost = ref(null)
const dictationRef = ref(null)
const pronunciationRef = ref(null)
const dictationRevealedKeys = ref(new Set())
const tabs = [
{ id: 'shadowing', label: 'Bắt chước phát âm' },
{ id: 'dictation', label: 'Nghe - Viết chính tả' },
{ id: 'pronunciation', label: 'Chỉnh phát âm' },
]
const dictationErrors = computed(() => dictationRef.value?.lastResult?.wrong?.length ?? 0)
const dictationReplays = computed(() => dictationRef.value?.replayCount ?? 0)
const dictationLastScore = computed(() => dictationRef.value?.lastScore ?? null)
const pronunciationScoreDisplay = computed(() => pronunciationRef.value?.score ?? '—')
const pronunciationTipDisplay = computed(() => {
const t = pronunciationRef.value?.tip
return typeof t === 'string' ? t : 'Nói rõ từng âm tiết, chú ý ngữ điệu lên xuống.'
})
function onRevealWord(segId, wi) {
const key = `${segId}-${wi}`
const next = new Set(dictationRevealedKeys.value)
next.add(key)
dictationRevealedKeys.value = next
}
function onRevealAllWords(segId, wordCount) {
const next = new Set(dictationRevealedKeys.value)
for (let wi = 0; wi < wordCount; wi++) next.add(`${segId}-${wi}`)
dictationRevealedKeys.value = next
}
function onTranscriptSeek(idx) {
playSegment(idx, { autoPlay: true })
}
function onDictationReplay() {
dictationRef.value?.onReplay?.()
replaySegment()
}
function onTogglePlay() {
if (playing.value) pause()
else playSegment(currentIndex.value)
}
function onDictationScored({ index, score }) {
const vid = props.videoData.video_id
const p = loadProgress(vid)
const scores = [...(p.dictationScores || [])]
scores[index] = score
saveProgress(vid, { dictationScores: scores })
}
function onPronunciationScored({ index, score }) {
const vid = props.videoData.video_id
const p = loadProgress(vid)
const scores = [...(p.pronunciationScores || [])]
scores[index] = score
saveProgress(vid, { pronunciationScores: scores })
}
function onKeydown(e) {
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return
if (e.code === 'Space') {
e.preventDefault()
onTogglePlay()
} else if (e.code === 'KeyR') {
replaySegment()
} else if (e.code === 'ArrowRight') {
nextSegment()
} else if (e.code === 'ArrowLeft') {
prevSegment()
}
}
watch(activeTab, (tab) => {
if (tab !== 'dictation') dictationRevealedKeys.value = new Set()
playSegment(currentIndex.value, { autoPlay: playing.value })
})
async function mountPlayer() {
if (!playerHost.value) return
await mountYtPlayer(playerHost.value, props.videoData.video_id, {
onTimeUpdate,
})
playSegment(currentIndex.value, { autoPlay: true })
}
const badgeCelebration = useBadgeCelebrationStore()
onMounted(async () => {
const vid = props.videoData.video_id
loadSaved(vid)
saveVideoMeta(vid, {
title: props.videoData.title,
level: props.videoData.level,
sourceUrl: props.videoData.source_url,
})
touchShadowingHistory(vid)
.then((res) => badgeCelebration.enqueue(res?.new_badges))
.catch(() => {})
window.addEventListener('keydown', onKeydown)
await mountPlayer()
})
watch(playerHost, async (el) => {
if (el && showVideo.value) await mountPlayer()
})
watch(showVideo, async (show) => {
if (!show) {
destroyPlayer()
return
}
await nextTick()
await mountPlayer()
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
destroyPlayer()
})
</script>