@linto-ai/transcript-ui-plugin-audio 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +28 -0
- package/package.json +46 -0
- package/src/AudioPlayer.vue +96 -0
- package/src/AudioPlayerControls.vue +207 -0
- package/src/index.ts +196 -0
- package/src/useAudioPlayer.ts +390 -0
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @linto-ai/transcript-ui-plugin-audio
|
|
2
|
+
|
|
3
|
+
Audio playback synced with the transcript: waveform display, active-word/active-turn highlighting as playback progresses, click-to-seek, and per-speaker region coloring on the waveform.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createAudioPlugin } from "@linto-ai/transcript-ui-plugin-audio"
|
|
9
|
+
|
|
10
|
+
core.use(
|
|
11
|
+
createAudioPlugin({
|
|
12
|
+
// Resolve a Channel/Translation's `audio.src` into a playable URL —
|
|
13
|
+
// add auth, fetch as a blob, whatever your backend requires.
|
|
14
|
+
// (generateUrl/fileId below are just this host's own routing — not
|
|
15
|
+
// part of this library.)
|
|
16
|
+
resolveSrc: async (source) => {
|
|
17
|
+
const response = await fetch(generateUrl(`apps/linto/api/audio/${fileId}`))
|
|
18
|
+
if (!response.ok) throw new Error("Audio unavailable")
|
|
19
|
+
const blob = await response.blob()
|
|
20
|
+
return URL.createObjectURL(blob)
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Any `blob:` URL returned by `resolveSrc` is revoked automatically when the source changes or the plugin is destroyed.
|
|
27
|
+
|
|
28
|
+
`resolveWaveform` is the same idea for precomputed waveform peaks (e.g. fetched from your API) — return `null` to fall back to client-side decoding.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@linto-ai/transcript-ui-plugin-audio",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Audio playback plugin for @linto-ai/transcript-ui — waveform, active-word highlighting, seek",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vue",
|
|
7
|
+
"vue3",
|
|
8
|
+
"transcript",
|
|
9
|
+
"audio",
|
|
10
|
+
"waveform",
|
|
11
|
+
"linto-plugin"
|
|
12
|
+
],
|
|
13
|
+
"sideEffects": [
|
|
14
|
+
"**/*.vue"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/linto-ai/linto-studio",
|
|
17
|
+
"bugs": "https://github.com/linto-ai/linto-studio/issues",
|
|
18
|
+
"author": "Tom Darboux <tdarboux@linagora.com>",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/linto-ai/linto-studio.git",
|
|
22
|
+
"directory": "studio-sdk/components/transcript-ui/packages/plugin-audio"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"license": "AGPL-3.0",
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src"
|
|
31
|
+
],
|
|
32
|
+
"main": "./src/index.ts",
|
|
33
|
+
"types": "./src/index.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": "./src/index.ts"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"vue": "^3.5.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@linto-ai/transcript-ui-core": "0.9.0",
|
|
42
|
+
"@linto-ai/transcript-ui-i18n": "0.9.0",
|
|
43
|
+
"@linto-ai/transcript-ui-ui": "0.9.0",
|
|
44
|
+
"wavesurfer.js": "^7.12.1"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, toRef } from 'vue'
|
|
3
|
+
import AudioPlayerControls from './AudioPlayerControls.vue'
|
|
4
|
+
import { useAudioPlayer } from './useAudioPlayer'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
audioSrc?: string
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const waveformRef = ref<HTMLElement | null>(null)
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
isPlaying,
|
|
14
|
+
isReady,
|
|
15
|
+
isLoading,
|
|
16
|
+
volume,
|
|
17
|
+
playbackRate,
|
|
18
|
+
isMuted,
|
|
19
|
+
formattedCurrentTime,
|
|
20
|
+
formattedDuration,
|
|
21
|
+
togglePlay,
|
|
22
|
+
seekTo,
|
|
23
|
+
pause,
|
|
24
|
+
skip,
|
|
25
|
+
setVolume,
|
|
26
|
+
cyclePlaybackRate,
|
|
27
|
+
toggleMute,
|
|
28
|
+
} = useAudioPlayer({
|
|
29
|
+
containerRef: waveformRef,
|
|
30
|
+
audioSrc: toRef(() => props.audioSrc),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
defineExpose({ seekTo, pause })
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<footer class="audio-player">
|
|
38
|
+
<div
|
|
39
|
+
ref="waveformRef"
|
|
40
|
+
class="waveform-container"
|
|
41
|
+
:class="{ 'waveform-container--loading': isLoading }" />
|
|
42
|
+
<AudioPlayerControls
|
|
43
|
+
:is-playing="isPlaying"
|
|
44
|
+
:current-time="formattedCurrentTime"
|
|
45
|
+
:duration="formattedDuration"
|
|
46
|
+
:volume="volume"
|
|
47
|
+
:playback-rate="playbackRate"
|
|
48
|
+
:is-muted="isMuted"
|
|
49
|
+
:is-ready="isReady"
|
|
50
|
+
@toggle-play="togglePlay"
|
|
51
|
+
@skip-back="skip(-10)"
|
|
52
|
+
@skip-forward="skip(10)"
|
|
53
|
+
@update:volume="setVolume"
|
|
54
|
+
@toggle-mute="toggleMute"
|
|
55
|
+
@cycle-playback-rate="cyclePlaybackRate" />
|
|
56
|
+
</footer>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<style scoped>
|
|
60
|
+
.audio-player {
|
|
61
|
+
border-top: 1px solid var(--color-border);
|
|
62
|
+
background-color: var(--color-surface);
|
|
63
|
+
flex-shrink: 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.waveform-container {
|
|
67
|
+
min-height: 32px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.waveform-container--loading {
|
|
71
|
+
background: linear-gradient(
|
|
72
|
+
90deg,
|
|
73
|
+
var(--color-border-light, var(--color-border)) 25%,
|
|
74
|
+
var(--color-border) 50%,
|
|
75
|
+
var(--color-border-light, var(--color-border)) 75%
|
|
76
|
+
);
|
|
77
|
+
background-size: 200% 100%;
|
|
78
|
+
animation: shimmer 1.5s ease-in-out infinite;
|
|
79
|
+
border-radius: var(--radius-sm);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@keyframes shimmer {
|
|
83
|
+
0% {
|
|
84
|
+
background-position: 200% 0;
|
|
85
|
+
}
|
|
86
|
+
100% {
|
|
87
|
+
background-position: -200% 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@media (prefers-reduced-motion: reduce) {
|
|
92
|
+
.waveform-container--loading {
|
|
93
|
+
animation: none;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
</style>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-vue-next'
|
|
4
|
+
import { Button } from '@linto-ai/transcript-ui-ui'
|
|
5
|
+
import { useI18n } from '@linto-ai/transcript-ui-i18n'
|
|
6
|
+
|
|
7
|
+
defineProps<{
|
|
8
|
+
isPlaying: boolean
|
|
9
|
+
currentTime: string
|
|
10
|
+
duration: string
|
|
11
|
+
volume: number
|
|
12
|
+
playbackRate: number
|
|
13
|
+
isMuted: boolean
|
|
14
|
+
isReady: boolean
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{
|
|
18
|
+
togglePlay: []
|
|
19
|
+
skipBack: []
|
|
20
|
+
skipForward: []
|
|
21
|
+
'update:volume': [value: number]
|
|
22
|
+
toggleMute: []
|
|
23
|
+
cyclePlaybackRate: []
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const { t } = useI18n()
|
|
27
|
+
|
|
28
|
+
const showVolumeSlider = ref(false)
|
|
29
|
+
|
|
30
|
+
function onVolumeInput(event: Event) {
|
|
31
|
+
const target = event.target as HTMLInputElement
|
|
32
|
+
emit('update:volume', parseFloat(target.value))
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<div class="player-controls">
|
|
38
|
+
<div class="controls-left">
|
|
39
|
+
<Button
|
|
40
|
+
variant="transparent"
|
|
41
|
+
size="md"
|
|
42
|
+
class="skip-button"
|
|
43
|
+
:aria-label="t('player.skipBack')"
|
|
44
|
+
:disabled="!isReady"
|
|
45
|
+
@click="emit('skipBack')"
|
|
46
|
+
>
|
|
47
|
+
<template #icon><SkipBack :size="16" /></template>
|
|
48
|
+
</Button>
|
|
49
|
+
|
|
50
|
+
<Button
|
|
51
|
+
variant="transparent"
|
|
52
|
+
size="md"
|
|
53
|
+
class="play-button"
|
|
54
|
+
:aria-label="isPlaying ? t('player.pause') : t('player.play')"
|
|
55
|
+
:disabled="!isReady"
|
|
56
|
+
@click="emit('togglePlay')"
|
|
57
|
+
>
|
|
58
|
+
<template #icon>
|
|
59
|
+
<Pause v-if="isPlaying" :size="20" />
|
|
60
|
+
<Play v-else :size="20" />
|
|
61
|
+
</template>
|
|
62
|
+
</Button>
|
|
63
|
+
|
|
64
|
+
<Button
|
|
65
|
+
variant="transparent"
|
|
66
|
+
size="md"
|
|
67
|
+
class="skip-button"
|
|
68
|
+
:aria-label="t('player.skipForward')"
|
|
69
|
+
:disabled="!isReady"
|
|
70
|
+
@click="emit('skipForward')"
|
|
71
|
+
>
|
|
72
|
+
<template #icon><SkipForward :size="16" /></template>
|
|
73
|
+
</Button>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="controls-time">
|
|
77
|
+
<time class="time-display">{{ currentTime }}</time>
|
|
78
|
+
<span class="time-separator">/</span>
|
|
79
|
+
<time class="time-display">{{ duration }}</time>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div class="controls-right">
|
|
83
|
+
<div
|
|
84
|
+
class="volume-group"
|
|
85
|
+
@mouseenter="showVolumeSlider = true"
|
|
86
|
+
@mouseleave="showVolumeSlider = false"
|
|
87
|
+
>
|
|
88
|
+
<Button
|
|
89
|
+
variant="transparent"
|
|
90
|
+
size="md"
|
|
91
|
+
:aria-label="isMuted ? t('player.unmute') : t('player.mute')"
|
|
92
|
+
:disabled="!isReady"
|
|
93
|
+
@click="emit('toggleMute')"
|
|
94
|
+
>
|
|
95
|
+
<template #icon>
|
|
96
|
+
<VolumeX v-if="isMuted" :size="16" />
|
|
97
|
+
<Volume2 v-else :size="16" />
|
|
98
|
+
</template>
|
|
99
|
+
</Button>
|
|
100
|
+
<input
|
|
101
|
+
v-show="showVolumeSlider"
|
|
102
|
+
type="range"
|
|
103
|
+
class="volume-slider"
|
|
104
|
+
min="0"
|
|
105
|
+
max="1"
|
|
106
|
+
step="0.05"
|
|
107
|
+
:value="volume"
|
|
108
|
+
:aria-label="t('player.volume')"
|
|
109
|
+
:disabled="!isReady"
|
|
110
|
+
@input="onVolumeInput"
|
|
111
|
+
>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<Button
|
|
115
|
+
variant="transparent"
|
|
116
|
+
size="md"
|
|
117
|
+
class="speed-button"
|
|
118
|
+
:aria-label="t('player.speed')"
|
|
119
|
+
:disabled="!isReady"
|
|
120
|
+
@click="emit('cyclePlaybackRate')"
|
|
121
|
+
>
|
|
122
|
+
{{ playbackRate }}x
|
|
123
|
+
</Button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
|
|
128
|
+
<style scoped>
|
|
129
|
+
.player-controls {
|
|
130
|
+
display: flex;
|
|
131
|
+
align-items: center;
|
|
132
|
+
gap: var(--spacing-md);
|
|
133
|
+
padding: var(--spacing-xs) var(--spacing-lg);
|
|
134
|
+
height: 44px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.controls-left {
|
|
138
|
+
display: flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: var(--spacing-xs);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.controls-time {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: var(--spacing-xxs);
|
|
147
|
+
font-family: var(--font-family-mono);
|
|
148
|
+
font-size: var(--font-size-sm);
|
|
149
|
+
color: var(--color-text-muted);
|
|
150
|
+
user-select: none;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.time-separator {
|
|
154
|
+
color: var(--color-text-muted);
|
|
155
|
+
opacity: 0.5;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.controls-right {
|
|
159
|
+
display: flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: var(--spacing-xs);
|
|
162
|
+
margin-left: auto;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.volume-group {
|
|
166
|
+
display: flex;
|
|
167
|
+
align-items: center;
|
|
168
|
+
gap: var(--spacing-xs);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.volume-slider {
|
|
172
|
+
width: 80px;
|
|
173
|
+
height: 4px;
|
|
174
|
+
accent-color: var(--color-primary);
|
|
175
|
+
cursor: pointer;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.volume-slider:disabled {
|
|
179
|
+
opacity: 0.5;
|
|
180
|
+
cursor: default;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.play-button {
|
|
184
|
+
width: 40px;
|
|
185
|
+
height: 40px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.speed-button {
|
|
189
|
+
font-size: var(--font-size-sm);
|
|
190
|
+
font-family: var(--font-family-mono);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@media (max-width: 767px) {
|
|
194
|
+
.skip-button {
|
|
195
|
+
display: none;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.volume-slider {
|
|
199
|
+
display: none;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.player-controls {
|
|
203
|
+
padding: var(--spacing-xs) var(--spacing-md);
|
|
204
|
+
gap: var(--spacing-sm);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
</style>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { ref, computed, watch, watchEffect } from "vue"
|
|
2
|
+
import type { Core, CorePlugin, AudioPluginApi, AudioSource } from "@linto-ai/transcript-ui-core"
|
|
3
|
+
import { utils } from "@linto-ai/transcript-ui-core"
|
|
4
|
+
import AudioPlayer from "./AudioPlayer.vue"
|
|
5
|
+
|
|
6
|
+
const { findActiveWord, firstWordStart, lastWordEnd } = utils
|
|
7
|
+
|
|
8
|
+
export type { AudioPluginApi }
|
|
9
|
+
export { AudioPlayer }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimum playback progress (in seconds of media time) between two
|
|
13
|
+
* activeWordId computations. Playback ticks ~60 Hz but the active word only
|
|
14
|
+
* changes at word boundaries (~4 Hz), so recomputing every frame is wasted
|
|
15
|
+
* work. Keep it well under a spoken word's duration so the highlight never
|
|
16
|
+
* lags perceptibly.
|
|
17
|
+
*/
|
|
18
|
+
const WORD_TRACK_INTERVAL = 0.05
|
|
19
|
+
|
|
20
|
+
export interface AudioPluginOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Resolves an `AudioSource` into a playable URL. Lets the host add a
|
|
23
|
+
* bearer token, fetch as a blob then `URL.createObjectURL`, etc.
|
|
24
|
+
* When absent, `source.src` is used as is.
|
|
25
|
+
*
|
|
26
|
+
* Any returned `blob:` URL is revoked automatically when the source
|
|
27
|
+
* changes or the plugin is destroyed.
|
|
28
|
+
*/
|
|
29
|
+
resolveSrc?: (source: AudioSource) => string | Promise<string>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolves precomputed waveform peaks for an `AudioSource` (e.g. fetched
|
|
33
|
+
* from the API). Raw amplitude values, any scale — the player normalizes
|
|
34
|
+
* them. Return null (or throw) to fall back to client-side decoding.
|
|
35
|
+
*/
|
|
36
|
+
resolveWaveform?: (
|
|
37
|
+
source: AudioSource,
|
|
38
|
+
) => number[] | null | Promise<number[] | null>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createAudioPlugin(
|
|
42
|
+
options: AudioPluginOptions = {},
|
|
43
|
+
): CorePlugin {
|
|
44
|
+
return {
|
|
45
|
+
name: "audio",
|
|
46
|
+
components: { player: AudioPlayer },
|
|
47
|
+
|
|
48
|
+
install(core: Core) {
|
|
49
|
+
const currentTime = ref(0)
|
|
50
|
+
const isPlaying = ref(false)
|
|
51
|
+
const activeWordId = ref<string | null>(null)
|
|
52
|
+
const activeTurnId = ref<string | null>(null)
|
|
53
|
+
let seekHandler: ((time: number) => void) | null = null
|
|
54
|
+
let pauseHandler: (() => void) | null = null
|
|
55
|
+
|
|
56
|
+
const rawSource = computed(
|
|
57
|
+
() => core.activeChannel.value?.activeTranslation.value.audio ?? null,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const resolvedSrc = ref<string | null>(null)
|
|
61
|
+
const waveform = ref<number[] | null>(null)
|
|
62
|
+
let ownedObjectUrl: string | null = null
|
|
63
|
+
|
|
64
|
+
function revokeOwned() {
|
|
65
|
+
if (ownedObjectUrl) {
|
|
66
|
+
URL.revokeObjectURL(ownedObjectUrl)
|
|
67
|
+
ownedObjectUrl = null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const stopSourceWatch = watch(
|
|
72
|
+
rawSource,
|
|
73
|
+
async (source) => {
|
|
74
|
+
revokeOwned()
|
|
75
|
+
resolvedSrc.value = null
|
|
76
|
+
waveform.value = null
|
|
77
|
+
if (!source) return
|
|
78
|
+
|
|
79
|
+
// Resolved alongside the src; a failure here only disables the
|
|
80
|
+
// precomputed waveform, never the audio itself.
|
|
81
|
+
const waveformPromise = options.resolveWaveform
|
|
82
|
+
? Promise.resolve(options.resolveWaveform(source)).catch((err) => {
|
|
83
|
+
console.warn("[audio] resolveWaveform failed", err)
|
|
84
|
+
return null
|
|
85
|
+
})
|
|
86
|
+
: Promise.resolve(null)
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const [url, peaks] = await Promise.all([
|
|
90
|
+
options.resolveSrc
|
|
91
|
+
? options.resolveSrc(source)
|
|
92
|
+
: Promise.resolve(source.src),
|
|
93
|
+
waveformPromise,
|
|
94
|
+
])
|
|
95
|
+
// Peaks are set before the src: the player creates WaveSurfer
|
|
96
|
+
// when the src changes and reads the waveform at that point.
|
|
97
|
+
waveform.value = peaks?.length ? peaks : null
|
|
98
|
+
resolvedSrc.value = url
|
|
99
|
+
if (url.startsWith("blob:")) ownedObjectUrl = url
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error("[audio] resolveSrc failed", err)
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
{ immediate: true },
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const src = computed(() => resolvedSrc.value)
|
|
108
|
+
|
|
109
|
+
// Media time of the last activeWordId computation, used to throttle.
|
|
110
|
+
// -Infinity forces a compute on the first tick after playback starts.
|
|
111
|
+
let lastComputeTime = Number.NEGATIVE_INFINITY
|
|
112
|
+
|
|
113
|
+
// Single source of truth: computes activeTurnId / activeWordId.
|
|
114
|
+
// No reset to null on pause: the last known position is kept.
|
|
115
|
+
const stopTracker = watchEffect(() => {
|
|
116
|
+
// Read BOTH refs unconditionally so the effect tracks currentTime even
|
|
117
|
+
// while paused — otherwise seeking/scrubbing (e.g. clicking a word)
|
|
118
|
+
// while paused would never move the highlight.
|
|
119
|
+
const time = currentTime.value
|
|
120
|
+
const playing = isPlaying.value
|
|
121
|
+
|
|
122
|
+
// Throttle only during continuous playback (the word can't have changed
|
|
123
|
+
// within WORD_TRACK_INTERVAL). A backward jump is negative and falls
|
|
124
|
+
// through; while paused, any seek recomputes.
|
|
125
|
+
if (playing) {
|
|
126
|
+
const elapsed = time - lastComputeTime
|
|
127
|
+
if (elapsed >= 0 && elapsed < WORD_TRACK_INTERVAL) return
|
|
128
|
+
}
|
|
129
|
+
lastComputeTime = time
|
|
130
|
+
|
|
131
|
+
const translation = core.activeChannel.value?.activeTranslation.value
|
|
132
|
+
if (!translation) return
|
|
133
|
+
|
|
134
|
+
for (const turn of translation.turns.value) {
|
|
135
|
+
// Derive the turn's span from its words: after a split, the turn
|
|
136
|
+
// attrs go stale (the first half keeps the whole original span, the
|
|
137
|
+
// second half has none), which would overlap and pick the wrong turn.
|
|
138
|
+
// The words carry the correct per-wid timestamps, so trust them and
|
|
139
|
+
// only fall back to the turn attrs for word-less (live) turns.
|
|
140
|
+
// First/last DEFINED word times: robust to words with no timestamp
|
|
141
|
+
// (freshly typed, or split/merge products) sitting anywhere, and to
|
|
142
|
+
// the stale turn attrs after a split. Fall back to the turn attrs for
|
|
143
|
+
// fully word-less (live text-only) turns.
|
|
144
|
+
const words = turn.words
|
|
145
|
+
const start = firstWordStart(words) ?? turn.startTime
|
|
146
|
+
const end = lastWordEnd(words) ?? turn.endTime
|
|
147
|
+
if (start != null && end != null && time >= start && time <= end) {
|
|
148
|
+
activeTurnId.value = turn.id
|
|
149
|
+
// Returns null when no timestamped word matches (e.g. the playhead
|
|
150
|
+
// sits over an untimed, just-typed word) — no stale highlight.
|
|
151
|
+
activeWordId.value = findActiveWord(words, time)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
function seekTo(time: number) {
|
|
158
|
+
seekHandler?.(time)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function setSeekHandler(fn: ((time: number) => void) | null) {
|
|
162
|
+
seekHandler = fn
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function pause() {
|
|
166
|
+
pauseHandler?.()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function setPauseHandler(fn: (() => void) | null) {
|
|
170
|
+
pauseHandler = fn
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const api: AudioPluginApi = {
|
|
174
|
+
currentTime,
|
|
175
|
+
isPlaying,
|
|
176
|
+
src,
|
|
177
|
+
waveform,
|
|
178
|
+
activeWordId,
|
|
179
|
+
activeTurnId,
|
|
180
|
+
seekTo,
|
|
181
|
+
setSeekHandler,
|
|
182
|
+
pause,
|
|
183
|
+
setPauseHandler,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
core.audio = api
|
|
187
|
+
|
|
188
|
+
return () => {
|
|
189
|
+
stopSourceWatch()
|
|
190
|
+
stopTracker()
|
|
191
|
+
revokeOwned()
|
|
192
|
+
core.audio = undefined
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
}
|