@linto-ai/transcript-ui-core 0.9.23 → 0.9.24-unstable.202610151359
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/README.md +32 -0
- package/package.json +3 -3
- package/src/components/Header.vue +54 -43
- package/src/components/Layout.vue +27 -8
- package/src/components/MicrophoneIllustration.vue +5 -5
- package/src/components/SpeakerLabel.vue +31 -16
- package/src/components/SpeakerSidebar.vue +74 -54
- package/src/components/SpeechActivityIndicator.vue +94 -0
- package/src/components/TranscriptUI.vue +33 -3
- package/src/components/TranscriptionPanel.vue +24 -3
- package/src/components/TranscriptionTurn.vue +33 -6
- package/src/composables/useFollowPlayback.ts +4 -1
- package/src/composables/useIsMobile.ts +8 -25
- package/src/core/capabilities.ts +18 -0
- package/src/core/createCore.ts +78 -1
- package/src/core/index.ts +7 -0
- package/src/core/modules/viewport.ts +48 -0
- package/src/core/stores/channelStore.ts +4 -1
- package/src/core/stores/test/translationStore.test.ts +35 -0
- package/src/core/stores/translationStore.ts +5 -2
- package/src/core/test/capabilities.test.ts +32 -0
- package/src/core/test/createCore.test.ts +248 -0
- package/src/core/types.ts +131 -6
- package/src/index.ts +9 -1
package/README.md
CHANGED
|
@@ -22,6 +22,38 @@ import { TranscriptUI, mapApiDocument } from "@linto-ai/transcript-ui-core"
|
|
|
22
22
|
|
|
23
23
|
If you don't want `TranscriptUI`'s default screen, `createCore`/`provideCore` and `Layout` are the two pieces it uses internally — use them directly to build your own.
|
|
24
24
|
|
|
25
|
+
## Driving the layout from the host
|
|
26
|
+
|
|
27
|
+
Rendering your own header (`<TranscriptUI no-header>`) removes the button that
|
|
28
|
+
opens the sidebar on phones — the sidebar's only entry point at that width.
|
|
29
|
+
Two things on `core` let your own header take that job over:
|
|
30
|
+
|
|
31
|
+
- `core.isMobile` — readonly ref, true at the width where the layout swaps its
|
|
32
|
+
permanent sidebar for a drawer. Read it instead of repeating the media query,
|
|
33
|
+
so your button shows up exactly when the drawer exists.
|
|
34
|
+
- `core.setSidebarOpen(open)` / `core.sidebarOpen` — open or close that drawer.
|
|
35
|
+
|
|
36
|
+
Opening is **refused** above the breakpoint, silently and without an event:
|
|
37
|
+
only the mobile, single-panel layout mounts a drawer, and an "open" stored
|
|
38
|
+
while none exists would spring it on the user at the next resize. Closing
|
|
39
|
+
always applies.
|
|
40
|
+
|
|
41
|
+
Two events keep a host in sync without watching a Vue ref — which matters when
|
|
42
|
+
the host isn't a Vue 3 app (a Vue 2 app, or plain JS around the web component,
|
|
43
|
+
can read `core.isMobile.value` but can't subscribe to it):
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
core.on("viewport:change", ({ isMobile }) => {
|
|
47
|
+
sidebarButton.hidden = !isMobile
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Your button is not the only thing that moves the drawer — its own close
|
|
51
|
+
// button, a channel change and leaving phone width also shut it.
|
|
52
|
+
core.on("sidebar:open", ({ open }) => {
|
|
53
|
+
sidebarButton.setAttribute("aria-expanded", String(open))
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
25
57
|
## Turning a transcript into something this package understands
|
|
26
58
|
|
|
27
59
|
- `mapApiDocument` — for a transcript from the LinTO Studio API.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linto-ai/transcript-ui-core",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.24-unstable.202610151359",
|
|
4
4
|
"description": "State layer and UI shell for @linto-ai/transcript-ui — core, stores, format adapters, and the TranscriptUI component",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vue",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"vue": "^3.5.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@linto-ai/transcript-ui-i18n": "0.9.
|
|
43
|
-
"@linto-ai/transcript-ui-ui": "0.9.
|
|
42
|
+
"@linto-ai/transcript-ui-i18n": "0.9.24-unstable.202610151359",
|
|
43
|
+
"@linto-ai/transcript-ui-ui": "0.9.24-unstable.202610151359",
|
|
44
44
|
"lucide-vue-next": "^0.564.0",
|
|
45
45
|
"reka-ui": "^2.8.0",
|
|
46
46
|
"vue-stick-to-bottom": "^1.0.0"
|
|
@@ -46,49 +46,54 @@ const metaParts = computed(() =>
|
|
|
46
46
|
|
|
47
47
|
<template>
|
|
48
48
|
<header class="editor-header">
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
49
|
+
<!-- Host-provided controls, e.g. a button reopening the app's menu.
|
|
50
|
+
Outside the padded content: they reach the header's edges. -->
|
|
51
|
+
<slot name="start" />
|
|
52
|
+
<div class="header-content">
|
|
53
|
+
<div class="header-main">
|
|
54
|
+
<h1 class="document-title">{{ formattedTitle }}</h1>
|
|
55
|
+
<div v-if="metaParts.length" class="document-meta">
|
|
56
|
+
<span
|
|
57
|
+
v-for="(part, i) in metaParts"
|
|
58
|
+
:key="i"
|
|
59
|
+
class="document-meta__part">
|
|
60
|
+
{{ part }}
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
58
63
|
</div>
|
|
59
|
-
|
|
60
|
-
<div class="header-right">
|
|
61
|
-
<Button
|
|
62
|
-
v-if="isMobile"
|
|
63
|
-
variant="transparent"
|
|
64
|
-
:aria-label="t('header.openSidebar')"
|
|
65
|
-
@click="$emit('toggleSidebar')">
|
|
66
|
-
<template #icon><EditorIcon name="users" :size="16" /></template>
|
|
67
|
-
</Button>
|
|
68
|
-
<template v-if="props.canUndo || props.canRedo">
|
|
64
|
+
<div class="header-right">
|
|
69
65
|
<Button
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
:
|
|
73
|
-
@click="$emit('
|
|
74
|
-
<template #icon><EditorIcon name="
|
|
66
|
+
v-if="isMobile"
|
|
67
|
+
variant="transparent"
|
|
68
|
+
:aria-label="t('header.openSidebar')"
|
|
69
|
+
@click="$emit('toggleSidebar')">
|
|
70
|
+
<template #icon><EditorIcon name="users" :size="16" /></template>
|
|
75
71
|
</Button>
|
|
72
|
+
<template v-if="props.canUndo || props.canRedo">
|
|
73
|
+
<Button
|
|
74
|
+
variant="secondary"
|
|
75
|
+
:aria-label="t('header.undo')"
|
|
76
|
+
:disabled="!props.canUndo"
|
|
77
|
+
@click="$emit('undo')">
|
|
78
|
+
<template #icon><EditorIcon name="undo" :size="16" /></template>
|
|
79
|
+
</Button>
|
|
80
|
+
<Button
|
|
81
|
+
variant="secondary"
|
|
82
|
+
:aria-label="t('header.redo')"
|
|
83
|
+
:disabled="!props.canRedo"
|
|
84
|
+
@click="$emit('redo')">
|
|
85
|
+
<template #icon><EditorIcon name="redo" :size="16" /></template>
|
|
86
|
+
</Button>
|
|
87
|
+
</template>
|
|
76
88
|
<Button
|
|
77
|
-
variant="
|
|
78
|
-
:aria-label="t('header.
|
|
79
|
-
:disabled="!props.
|
|
80
|
-
@click="$emit('
|
|
81
|
-
<template #icon><EditorIcon name="
|
|
89
|
+
variant="primary"
|
|
90
|
+
:aria-label="t('header.ask')"
|
|
91
|
+
:disabled="!props.canAsk"
|
|
92
|
+
@click="$emit('openChat')">
|
|
93
|
+
<template #icon><EditorIcon name="sparkles" :size="16" /></template>
|
|
94
|
+
<span v-if="!isMobile">{{ t("header.ask") }}</span>
|
|
82
95
|
</Button>
|
|
83
|
-
</
|
|
84
|
-
<Button
|
|
85
|
-
variant="primary"
|
|
86
|
-
:aria-label="t('header.ask')"
|
|
87
|
-
:disabled="!props.canAsk"
|
|
88
|
-
@click="$emit('openChat')">
|
|
89
|
-
<template #icon><EditorIcon name="sparkles" :size="16" /></template>
|
|
90
|
-
<span v-if="!isMobile">{{ t("header.ask") }}</span>
|
|
91
|
-
</Button>
|
|
96
|
+
</div>
|
|
92
97
|
</div>
|
|
93
98
|
</header>
|
|
94
99
|
</template>
|
|
@@ -96,16 +101,22 @@ const metaParts = computed(() =>
|
|
|
96
101
|
<style scoped>
|
|
97
102
|
.editor-header {
|
|
98
103
|
display: flex;
|
|
99
|
-
align-items: center;
|
|
100
|
-
justify-content: space-between;
|
|
101
|
-
gap: var(--spacing-md);
|
|
102
|
-
padding: var(--spacing-sm) var(--spacing-lg);
|
|
103
104
|
min-height: var(--header-height);
|
|
104
105
|
border-bottom: 1px solid var(--color-border);
|
|
105
106
|
background-color: var(--color-surface);
|
|
106
107
|
flex-shrink: 0;
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
.header-content {
|
|
111
|
+
display: flex;
|
|
112
|
+
flex: 1;
|
|
113
|
+
min-width: 0;
|
|
114
|
+
align-items: center;
|
|
115
|
+
justify-content: space-between;
|
|
116
|
+
gap: var(--spacing-md);
|
|
117
|
+
padding: var(--spacing-sm) var(--spacing-lg);
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
.header-main {
|
|
110
121
|
display: flex;
|
|
111
122
|
flex-direction: column;
|
|
@@ -150,7 +161,7 @@ const metaParts = computed(() =>
|
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
@media (max-width: 767px) {
|
|
153
|
-
.
|
|
164
|
+
.header-content {
|
|
154
165
|
padding: var(--spacing-xs) var(--spacing-md);
|
|
155
166
|
}
|
|
156
167
|
|
|
@@ -12,22 +12,23 @@ import TranslationSelector from "./TranslationSelector.vue"
|
|
|
12
12
|
import SelectionActionBar from "./SelectionActionBar.vue"
|
|
13
13
|
import { useIsMobile } from "../composables/useIsMobile"
|
|
14
14
|
import { provideTurnSelection } from "../composables/useTurnSelection"
|
|
15
|
-
import { useCore, type LLMService } from "../core"
|
|
15
|
+
import { useCore, computeCanChat, type LLMService } from "../core"
|
|
16
16
|
|
|
17
17
|
const props = withDefaults(
|
|
18
18
|
defineProps<{
|
|
19
19
|
showHeader?: boolean
|
|
20
20
|
showVerbatim?: boolean
|
|
21
|
+
showThemeToggle?: boolean
|
|
21
22
|
}>(),
|
|
22
23
|
{
|
|
23
24
|
showHeader: true,
|
|
24
25
|
showVerbatim: true,
|
|
26
|
+
showThemeToggle: true,
|
|
25
27
|
},
|
|
26
28
|
)
|
|
27
29
|
|
|
28
30
|
const core = useCore()
|
|
29
31
|
const { isMobile } = useIsMobile()
|
|
30
|
-
const isSidebarOpen = ref(false)
|
|
31
32
|
|
|
32
33
|
const shownPanels = ref<string[]>([TRANSCRIPTION_TAB])
|
|
33
34
|
// The ?? never actually triggers — every shownPanels mutation keeps at least one entry — it's just satisfying noUncheckedIndexedAccess.
|
|
@@ -87,6 +88,17 @@ const speakerList = computed(() => Array.from(speakers.values()))
|
|
|
87
88
|
|
|
88
89
|
const showTranscription = computed(() => activeTab.value === TRANSCRIPTION_TAB)
|
|
89
90
|
|
|
91
|
+
// Installing the chat plugin offers the chat; the capability says whether
|
|
92
|
+
// this reader may use it. core.chat is a plain field, invisible to Vue: the
|
|
93
|
+
// drawer the plugin registers in the reactive core.components is what makes
|
|
94
|
+
// a late install (the host checks first that its backend offers the chat)
|
|
95
|
+
// show up. core.chat is still read, by the template: it is unset again when
|
|
96
|
+
// the plugin is released, while its component stays registered.
|
|
97
|
+
const chatOffered = computed(
|
|
98
|
+
() =>
|
|
99
|
+
!!core.components.chatDrawer && computeCanChat(core.capabilities.value),
|
|
100
|
+
)
|
|
101
|
+
|
|
90
102
|
// remove split when shrinking in mobile view
|
|
91
103
|
watch(isMobile, (mobile) => {
|
|
92
104
|
if (mobile && isSplit.value) isSplit.value = false
|
|
@@ -138,7 +150,7 @@ watch(
|
|
|
138
150
|
core.audio.currentTime.value = 0
|
|
139
151
|
core.audio.isPlaying.value = false
|
|
140
152
|
}
|
|
141
|
-
|
|
153
|
+
core.setSidebarOpen(false)
|
|
142
154
|
},
|
|
143
155
|
)
|
|
144
156
|
|
|
@@ -164,13 +176,15 @@ function onTranslationChange(translationId: string) {
|
|
|
164
176
|
:duration="core.activeChannel.value?.duration ?? 0"
|
|
165
177
|
:speaker-count="speakers.size"
|
|
166
178
|
:is-mobile="isMobile"
|
|
167
|
-
:can-ask="!!core.chat"
|
|
179
|
+
:can-ask="chatOffered && !!core.chat"
|
|
168
180
|
:can-undo="core.transcriptionEditor?.canUndo.value ?? false"
|
|
169
181
|
:can-redo="core.transcriptionEditor?.canRedo.value ?? false"
|
|
170
|
-
@toggle-sidebar="
|
|
182
|
+
@toggle-sidebar="core.setSidebarOpen(!core.sidebarOpen.value)"
|
|
171
183
|
@open-chat="core.chat?.setDrawerOpen(true)"
|
|
172
184
|
@undo="core.transcriptionEditor?.undo()"
|
|
173
|
-
@redo="core.transcriptionEditor?.redo()"
|
|
185
|
+
@redo="core.transcriptionEditor?.redo()">
|
|
186
|
+
<template #start><slot name="header-start" /></template>
|
|
187
|
+
</Header>
|
|
174
188
|
<TabBar
|
|
175
189
|
:model-value="activeTab"
|
|
176
190
|
:show-verbatim="props.showVerbatim"
|
|
@@ -203,12 +217,14 @@ function onTranslationChange(translationId: string) {
|
|
|
203
217
|
:translations="translations"
|
|
204
218
|
:selected-translation-id="activeTranslationId"
|
|
205
219
|
:show-speakers="showTranscription"
|
|
220
|
+
:show-theme-toggle="props.showThemeToggle"
|
|
206
221
|
@update:selected-channel-id="onChannelChange"
|
|
207
222
|
@update:selected-translation-id="onTranslationChange" />
|
|
208
223
|
|
|
209
224
|
<SidebarDrawer
|
|
210
225
|
v-if="isMobile && panels.length === 1"
|
|
211
|
-
|
|
226
|
+
:open="core.sidebarOpen.value"
|
|
227
|
+
@update:open="core.setSidebarOpen($event)">
|
|
212
228
|
<SpeakerSidebar
|
|
213
229
|
:speakers="speakerList"
|
|
214
230
|
:channels="channels"
|
|
@@ -216,6 +232,7 @@ function onTranslationChange(translationId: string) {
|
|
|
216
232
|
:translations="translations"
|
|
217
233
|
:selected-translation-id="activeTranslationId"
|
|
218
234
|
:show-speakers="showTranscription"
|
|
235
|
+
:show-theme-toggle="props.showThemeToggle"
|
|
219
236
|
@update:selected-channel-id="onChannelChange"
|
|
220
237
|
@update:selected-translation-id="onTranslationChange" />
|
|
221
238
|
</SidebarDrawer>
|
|
@@ -235,7 +252,9 @@ function onTranslationChange(translationId: string) {
|
|
|
235
252
|
<component
|
|
236
253
|
:is="core.components.subtitleFullscreen"
|
|
237
254
|
v-if="core.subtitle?.isFullscreen.value" />
|
|
238
|
-
<component
|
|
255
|
+
<component
|
|
256
|
+
:is="core.components.chatDrawer"
|
|
257
|
+
v-if="chatOffered && !!core.chat" />
|
|
239
258
|
<div
|
|
240
259
|
v-if="isMobile && (channels.length > 1 || translations.length > 1)"
|
|
241
260
|
class="mobile-selectors">
|
|
@@ -5,24 +5,24 @@
|
|
|
5
5
|
xmlns="http://www.w3.org/2000/svg">
|
|
6
6
|
<polygon
|
|
7
7
|
points="331.5,533.5 331.5,520.5 702.5,428.5 705.5,443.5"
|
|
8
|
-
fill="
|
|
8
|
+
fill="currentColor"
|
|
9
9
|
transform="matrix(1.8176168,0,0,1.8176168,-452.14416,-495.30213)" />
|
|
10
10
|
<polygon
|
|
11
11
|
points="564.5,469.5 555.5,452.5 544.5,455.5 542.5,472.5"
|
|
12
|
-
fill="
|
|
12
|
+
fill="currentColor"
|
|
13
13
|
transform="matrix(1.8176168,0,0,1.8176168,-452.14416,-495.30213)" />
|
|
14
14
|
<path
|
|
15
15
|
d="m 317.61655,19.99224 c 0,0 79.97514,-5.452851 101.78654,56.34612 21.81141,61.79897 72.70468,172.67359 92.69846,189.03214 19.99379,16.35855 41.80519,59.98136 38.16995,74.52229"
|
|
16
|
-
stroke="
|
|
16
|
+
stroke="currentColor"
|
|
17
17
|
stroke-miterlimit="10"
|
|
18
18
|
stroke-width="9.08808" />
|
|
19
19
|
<path
|
|
20
20
|
d="m 329.43106,19.083431 c 0,8.532657 -9.0733,15.449743 -23.62902,15.449743 -14.55571,0 -21.8114,-6.917086 -21.8114,-15.449743 0,-8.532657 7.25569,-15.4497427 21.8114,-15.4497427 14.55572,0 23.62902,6.9170857 23.62902,15.4497427 z"
|
|
21
21
|
fill="currentColor"
|
|
22
|
-
style="fill:
|
|
22
|
+
style="fill: currentColor; opacity: 0.6" />
|
|
23
23
|
<polygon
|
|
24
24
|
points="691.5,439.5 364.5,521.5 377.5,602.5 666.5,602.5"
|
|
25
|
-
fill="
|
|
25
|
+
fill="currentColor"
|
|
26
26
|
transform="matrix(1.8176168,0,0,1.8176168,-456.32371,-492.51252)" />
|
|
27
27
|
</svg>
|
|
28
28
|
</template>
|
|
@@ -5,15 +5,22 @@ import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
|
5
5
|
import * as utils from "../utils"
|
|
6
6
|
import type { Speaker } from "../types/editor"
|
|
7
7
|
|
|
8
|
-
const props =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
const props = withDefaults(
|
|
9
|
+
defineProps<{
|
|
10
|
+
speaker?: Speaker
|
|
11
|
+
startTime?: number
|
|
12
|
+
startDate?: number
|
|
13
|
+
language: string
|
|
14
|
+
/** The label is wrapped in a clickable trigger (speaker assignment):
|
|
15
|
+
* show the hover affordance on the name, and the placeholder that
|
|
16
|
+
* invites the click when nobody is assigned yet. */
|
|
17
|
+
interactive?: boolean
|
|
18
|
+
/** False on a translation track, where every turn is in the same language
|
|
19
|
+
* and the selector already names it. */
|
|
20
|
+
showLanguage?: boolean
|
|
21
|
+
}>(),
|
|
22
|
+
{ showLanguage: true },
|
|
23
|
+
)
|
|
17
24
|
|
|
18
25
|
const { t, locale } = useI18n()
|
|
19
26
|
|
|
@@ -44,9 +51,12 @@ const timestamp = computed<{ text: string; datetime: string } | null>(() => {
|
|
|
44
51
|
|
|
45
52
|
const speakerColor = computed(() => props.speaker?.color ?? "transparent")
|
|
46
53
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
54
|
+
// The placeholder for an unnamed speaker is an invitation to assign one, so it
|
|
55
|
+
// belongs where the label is a trigger. Read-only — a live session has no
|
|
56
|
+
// speaker assignment — it would only be noise nobody can act on.
|
|
57
|
+
const displayName = computed(
|
|
58
|
+
() => props.speaker?.name ?? (props.interactive ? t("speaker.unknown") : ""),
|
|
59
|
+
)
|
|
50
60
|
</script>
|
|
51
61
|
|
|
52
62
|
<template>
|
|
@@ -54,13 +64,18 @@ const displayName = computed(() => props.speaker?.name ?? t("speaker.unknown"))
|
|
|
54
64
|
class="speaker-label"
|
|
55
65
|
:class="{ 'speaker-label--interactive': interactive }">
|
|
56
66
|
<SpeakerIndicator v-if="speaker" :color="speakerColor" />
|
|
57
|
-
<span
|
|
58
|
-
displayName
|
|
59
|
-
|
|
67
|
+
<span
|
|
68
|
+
v-if="displayName"
|
|
69
|
+
class="speaker-name"
|
|
70
|
+
:class="{ 'speaker-name--unknown': !speaker }"
|
|
71
|
+
>{{ displayName }}</span
|
|
72
|
+
>
|
|
60
73
|
<time v-if="timestamp" class="timestamp" :datetime="timestamp.datetime">{{
|
|
61
74
|
timestamp.text
|
|
62
75
|
}}</time>
|
|
63
|
-
<span v-if="languageName" class="lang">{{
|
|
76
|
+
<span v-if="showLanguage && languageName" class="lang">{{
|
|
77
|
+
languageName
|
|
78
|
+
}}</span>
|
|
64
79
|
</div>
|
|
65
80
|
</template>
|
|
66
81
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { SpeakerIndicator, SwitchToggle, EditableText, SelectableListItem, EditorIcon, SpeakerMenu } from "@linto-ai/transcript-ui-ui"
|
|
3
|
-
import { computed, ref } from "vue"
|
|
2
|
+
import { SpeakerIndicator, SwitchToggle, EditableText, SelectableListItem, EditorIcon, RangeSlider, SpeakerMenu } from "@linto-ai/transcript-ui-ui"
|
|
3
|
+
import { computed, ref, useId } from "vue"
|
|
4
4
|
import ChannelSelector from "./ChannelSelector.vue"
|
|
5
5
|
import TranslationSelector from "./TranslationSelector.vue"
|
|
6
6
|
import MergeDialog from "./molecules/MergeDialog.vue"
|
|
7
7
|
import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
8
8
|
import { useCore } from "../core"
|
|
9
|
+
import { useIsMobile } from "../composables/useIsMobile"
|
|
9
10
|
import { renameSpeaker } from "../core/helpers"
|
|
10
11
|
import type { Speaker } from "../types/editor"
|
|
11
12
|
import type { LLMServiceGenerationStatus, TranslationInfo } from "../core"
|
|
@@ -18,8 +19,9 @@ withDefaults(
|
|
|
18
19
|
translations: TranslationInfo[]
|
|
19
20
|
selectedTranslationId: string
|
|
20
21
|
showSpeakers?: boolean
|
|
22
|
+
showThemeToggle?: boolean
|
|
21
23
|
}>(),
|
|
22
|
-
{ showSpeakers: true },
|
|
24
|
+
{ showSpeakers: true, showThemeToggle: true },
|
|
23
25
|
)
|
|
24
26
|
|
|
25
27
|
defineEmits<{
|
|
@@ -29,6 +31,7 @@ defineEmits<{
|
|
|
29
31
|
|
|
30
32
|
const core = useCore()
|
|
31
33
|
const { t } = useI18n()
|
|
34
|
+
const { isMobile } = useIsMobile()
|
|
32
35
|
|
|
33
36
|
const canEditSpeakers = computed(
|
|
34
37
|
() => core.capabilities.value.speakers === "edit",
|
|
@@ -47,6 +50,21 @@ function onToggleTts(value: boolean): void {
|
|
|
47
50
|
else core.live.disableTTS()
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
// The switch is a bare checkbox with no built-in name, so the visible text
|
|
54
|
+
// has to be a real <label> pointing at it.
|
|
55
|
+
const partialsToggleId = useId()
|
|
56
|
+
const darkThemeToggleId = useId()
|
|
57
|
+
|
|
58
|
+
function onToggleDarkTheme(value: boolean): void {
|
|
59
|
+
core.theme.value = value ? "dark" : "light"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function onTogglePartials(value: boolean): void {
|
|
63
|
+
if (!core.live) return
|
|
64
|
+
if (value) core.live.showPartials()
|
|
65
|
+
else core.live.hidePartials()
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
const mergeOpen = ref(false)
|
|
51
69
|
const mergeFromId = ref<string | null>(null)
|
|
52
70
|
|
|
@@ -146,32 +164,45 @@ function onSelectVersion(versionNumber: number): void {
|
|
|
146
164
|
$emit('update:selectedTranslationId', $event)
|
|
147
165
|
" />
|
|
148
166
|
</section>
|
|
149
|
-
|
|
167
|
+
<!-- Scoped to live sessions for now: the font size is a core setting, but
|
|
168
|
+
it is only offered where it was asked for. The reading size comes
|
|
169
|
+
first — it stays meaningful the day this section shows outside a live
|
|
170
|
+
session, where the partials toggle would not. -->
|
|
171
|
+
<section v-if="core.live" class="sidebar-section">
|
|
172
|
+
<h2 class="sidebar-title">{{ t("sidebar.transcription") }}</h2>
|
|
173
|
+
<RangeSlider
|
|
174
|
+
v-model="core.transcriptFontSize.value"
|
|
175
|
+
:label="t('transcription.fontSize')"
|
|
176
|
+
:min="14"
|
|
177
|
+
:max="28"
|
|
178
|
+
:step="2" />
|
|
179
|
+
<div class="subtitle-toggle">
|
|
180
|
+
<label class="subtitle-toggle-label" :for="partialsToggleId">
|
|
181
|
+
{{ t("live.showPartials") }}
|
|
182
|
+
</label>
|
|
183
|
+
<SwitchToggle
|
|
184
|
+
:id="partialsToggleId"
|
|
185
|
+
:model-value="core.live.partialsVisible.value"
|
|
186
|
+
@update:model-value="onTogglePartials" />
|
|
187
|
+
</div>
|
|
188
|
+
</section>
|
|
189
|
+
<!-- Subtitles are a desktop surface: Layout only mounts the banner above
|
|
190
|
+
the breakpoint, and the fullscreen view is opened by the host, not
|
|
191
|
+
from here. On a phone the whole section is inert — including the font
|
|
192
|
+
size and watermark controls, which hang off the switch above them. -->
|
|
193
|
+
<section v-if="core.subtitle && !isMobile" class="sidebar-section">
|
|
150
194
|
<h2 class="sidebar-title">{{ t("sidebar.subtitle") }}</h2>
|
|
151
195
|
<div class="subtitle-toggle">
|
|
152
196
|
<span class="subtitle-toggle-label">{{ t("subtitle.show") }}</span>
|
|
153
197
|
<SwitchToggle v-model="core.subtitle.isVisible.value" />
|
|
154
198
|
</div>
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
<input
|
|
163
|
-
type="range"
|
|
164
|
-
:min="20"
|
|
165
|
-
:max="80"
|
|
166
|
-
:step="2"
|
|
167
|
-
:value="core.subtitle.fontSize.value"
|
|
168
|
-
:disabled="!core.subtitle.isVisible.value"
|
|
169
|
-
@input="
|
|
170
|
-
core.subtitle!.fontSize.value = Number(
|
|
171
|
-
($event.target as HTMLInputElement).value,
|
|
172
|
-
)
|
|
173
|
-
" />
|
|
174
|
-
</label>
|
|
199
|
+
<RangeSlider
|
|
200
|
+
v-model="core.subtitle.fontSize.value"
|
|
201
|
+
:label="t('subtitle.fontSize')"
|
|
202
|
+
:min="20"
|
|
203
|
+
:max="80"
|
|
204
|
+
:step="2"
|
|
205
|
+
:disabled="!core.subtitle.isVisible.value" />
|
|
175
206
|
<div
|
|
176
207
|
v-if="core.subtitle.watermark && !core.subtitle.watermark.readonly"
|
|
177
208
|
class="subtitle-toggle">
|
|
@@ -283,6 +314,20 @@ function onSelectVersion(versionNumber: number): void {
|
|
|
283
314
|
</li>
|
|
284
315
|
</ul>
|
|
285
316
|
</section>
|
|
317
|
+
<!-- Its own section, and never gated: the theme belongs to the reader,
|
|
318
|
+
not to a plugin or to a live session. -->
|
|
319
|
+
<section v-if="showThemeToggle" class="sidebar-section">
|
|
320
|
+
<h2 class="sidebar-title">{{ t("sidebar.appearance") }}</h2>
|
|
321
|
+
<div class="subtitle-toggle">
|
|
322
|
+
<label class="subtitle-toggle-label" :for="darkThemeToggleId">
|
|
323
|
+
{{ t("theme.highContrast") }}
|
|
324
|
+
</label>
|
|
325
|
+
<SwitchToggle
|
|
326
|
+
:id="darkThemeToggleId"
|
|
327
|
+
:model-value="core.theme.value === 'dark'"
|
|
328
|
+
@update:model-value="onToggleDarkTheme" />
|
|
329
|
+
</div>
|
|
330
|
+
</section>
|
|
286
331
|
<MergeDialog
|
|
287
332
|
v-if="canEditSpeakers"
|
|
288
333
|
v-model:open="mergeOpen"
|
|
@@ -355,6 +400,10 @@ function onSelectVersion(versionNumber: number): void {
|
|
|
355
400
|
color: var(--color-text-primary);
|
|
356
401
|
}
|
|
357
402
|
|
|
403
|
+
label.subtitle-toggle-label {
|
|
404
|
+
cursor: pointer;
|
|
405
|
+
}
|
|
406
|
+
|
|
358
407
|
.voice-playback-hint {
|
|
359
408
|
padding: 0 var(--spacing-sm);
|
|
360
409
|
font-size: var(--font-size-xs);
|
|
@@ -365,35 +414,6 @@ function onSelectVersion(versionNumber: number): void {
|
|
|
365
414
|
color: var(--color-danger);
|
|
366
415
|
}
|
|
367
416
|
|
|
368
|
-
.subtitle-slider {
|
|
369
|
-
display: flex;
|
|
370
|
-
flex-direction: column;
|
|
371
|
-
gap: var(--spacing-xs);
|
|
372
|
-
padding: var(--spacing-sm);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
.subtitle-slider-label {
|
|
376
|
-
display: flex;
|
|
377
|
-
justify-content: space-between;
|
|
378
|
-
font-size: var(--font-size-sm);
|
|
379
|
-
color: var(--color-text-primary);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
.subtitle-slider-value {
|
|
383
|
-
color: var(--color-text-muted);
|
|
384
|
-
font-variant-numeric: tabular-nums;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
.subtitle-slider input[type="range"] {
|
|
388
|
-
width: 100%;
|
|
389
|
-
accent-color: var(--color-primary);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
.subtitle-slider input[type="range"]:disabled {
|
|
393
|
-
opacity: 0.4;
|
|
394
|
-
cursor: not-allowed;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
417
|
/* ── History (LLM generations + versions) ──────────────────────────── */
|
|
398
418
|
|
|
399
419
|
.sidebar-section--busy {
|
|
@@ -416,7 +436,7 @@ function onSelectVersion(versionNumber: number): void {
|
|
|
416
436
|
}
|
|
417
437
|
|
|
418
438
|
.history-generation__status--completed {
|
|
419
|
-
color: var(--color-success
|
|
439
|
+
color: var(--color-success);
|
|
420
440
|
}
|
|
421
441
|
|
|
422
442
|
.history-generation__status--error {
|