@linto-ai/transcript-ui-core 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.
Files changed (101) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +46 -0
  3. package/package.json +46 -0
  4. package/src/adapters/apiAdapter.ts +44 -0
  5. package/src/adapters/mapApiTurns.ts +32 -0
  6. package/src/adapters/test/mapApiTurns.test.ts +73 -0
  7. package/src/adapters/whisperXAdapter.ts +73 -0
  8. package/src/components/ChannelSelector.vue +31 -0
  9. package/src/components/EditorErrorOverlay.vue +55 -0
  10. package/src/components/EditorLoadingOverlay.vue +52 -0
  11. package/src/components/Header.vue +138 -0
  12. package/src/components/Layout.vue +221 -0
  13. package/src/components/MicrophoneIllustration.vue +28 -0
  14. package/src/components/SelectionActionBar.vue +86 -0
  15. package/src/components/SidebarDrawer.vue +32 -0
  16. package/src/components/SpeakerLabel.vue +105 -0
  17. package/src/components/SpeakerSidebar.vue +455 -0
  18. package/src/components/TabBar.constants.ts +2 -0
  19. package/src/components/TabBar.vue +52 -0
  20. package/src/components/TranscriptUI.vue +68 -0
  21. package/src/components/TranscriptionEmpty.vue +39 -0
  22. package/src/components/TranscriptionPanel.vue +275 -0
  23. package/src/components/TranscriptionTurn.vue +407 -0
  24. package/src/components/TranslationSelector.vue +39 -0
  25. package/src/components/VerbatimPanel.vue +160 -0
  26. package/src/components/molecules/MergeDialog.vue +150 -0
  27. package/src/components/molecules/MergeTurnsButton.vue +57 -0
  28. package/src/components/molecules/SpeakerPopover.vue +146 -0
  29. package/src/composables/useEditorReady.ts +62 -0
  30. package/src/composables/useFollowPlayback.ts +124 -0
  31. package/src/composables/useIsMobile.ts +24 -0
  32. package/src/composables/useTurnSelection.ts +169 -0
  33. package/src/constants/speakers.ts +14 -0
  34. package/src/core/createCore.ts +162 -0
  35. package/src/core/helpers/addSpeaker.ts +11 -0
  36. package/src/core/helpers/countTurnsForSpeaker.ts +9 -0
  37. package/src/core/helpers/createSpeakerAndAssign.ts +16 -0
  38. package/src/core/helpers/ensureDocumentSpeakers.ts +26 -0
  39. package/src/core/helpers/ensureSpeaker.ts +12 -0
  40. package/src/core/helpers/ensureSpeakersFromTurns.ts +14 -0
  41. package/src/core/helpers/findTurnIndex.ts +5 -0
  42. package/src/core/helpers/index.ts +15 -0
  43. package/src/core/helpers/insertTurn.ts +5 -0
  44. package/src/core/helpers/mergeSpeakers.ts +29 -0
  45. package/src/core/helpers/patchTurn.ts +15 -0
  46. package/src/core/helpers/prependTurns.ts +5 -0
  47. package/src/core/helpers/removeTurn.ts +8 -0
  48. package/src/core/helpers/renameSpeaker.ts +12 -0
  49. package/src/core/helpers/speakerEquals.ts +6 -0
  50. package/src/core/helpers/switchTurnSpeaker.ts +17 -0
  51. package/src/core/helpers/test/countTurnsForSpeaker.test.ts +16 -0
  52. package/src/core/helpers/test/createSpeakerAndAssign.test.ts +22 -0
  53. package/src/core/helpers/test/makeTestCore.ts +40 -0
  54. package/src/core/helpers/test/mergeSpeakers.test.ts +24 -0
  55. package/src/core/helpers/test/renameSpeaker.test.ts +19 -0
  56. package/src/core/helpers/test/switchTurnSpeaker.test.ts +27 -0
  57. package/src/core/helpers/updateTurnWords.ts +22 -0
  58. package/src/core/index.ts +47 -0
  59. package/src/core/modules/eventBus.ts +43 -0
  60. package/src/core/stores/channelStore.ts +100 -0
  61. package/src/core/stores/crossTranslationStore.ts +141 -0
  62. package/src/core/stores/index.ts +7 -0
  63. package/src/core/stores/speakersStore.ts +53 -0
  64. package/src/core/stores/translationStore.ts +133 -0
  65. package/src/core/types.ts +602 -0
  66. package/src/core/useCore.ts +16 -0
  67. package/src/index.ts +88 -0
  68. package/src/types/api.ts +52 -0
  69. package/src/types/editor.ts +65 -0
  70. package/src/types/whisperx.ts +22 -0
  71. package/src/utils/color.ts +7 -0
  72. package/src/utils/computeCaretOffsetFromPoint.ts +56 -0
  73. package/src/utils/computeTurnPlainText.ts +9 -0
  74. package/src/utils/extractLangCode.ts +7 -0
  75. package/src/utils/findWordAtOffset.ts +16 -0
  76. package/src/utils/index.ts +19 -0
  77. package/src/utils/intl.ts +47 -0
  78. package/src/utils/isSameLanguage.ts +13 -0
  79. package/src/utils/test/computeTurnPlainText.test.ts +34 -0
  80. package/src/utils/test/findWordAtOffset.test.ts +25 -0
  81. package/src/utils/throttle.ts +23 -0
  82. package/src/utils/time.ts +95 -0
  83. package/src/utils/tokenize.ts +34 -0
  84. package/src/utils/tts.ts +49 -0
  85. package/src/utils/turnWords/carryWordTimes.ts +37 -0
  86. package/src/utils/turnWords/index.ts +6 -0
  87. package/src/utils/turnWords/layoutWords.ts +41 -0
  88. package/src/utils/turnWords/parseWordId.ts +13 -0
  89. package/src/utils/turnWords/test/carryWordTimes.test.ts +47 -0
  90. package/src/utils/turnWords/test/layoutWords.test.ts +54 -0
  91. package/src/utils/turnWords/test/parseWordId.test.ts +24 -0
  92. package/src/utils/turnWords/test/wordId.test.ts +9 -0
  93. package/src/utils/turnWords/test/wordsFromApi.test.ts +20 -0
  94. package/src/utils/turnWords/test/wordsFromText.test.ts +17 -0
  95. package/src/utils/turnWords/wordId.ts +9 -0
  96. package/src/utils/turnWords/wordsFromApi.ts +16 -0
  97. package/src/utils/turnWords/wordsFromText.ts +14 -0
  98. package/src/utils/validateDocument.ts +80 -0
  99. package/src/utils/waveform.ts +64 -0
  100. package/src/utils/wordRange.ts +67 -0
  101. package/src/utils/words.ts +45 -0
@@ -0,0 +1,12 @@
1
+ import type { Core } from "../types"
2
+
3
+ export function renameSpeaker(
4
+ core: Core,
5
+ speakerId: string,
6
+ newName: string,
7
+ ): void {
8
+ const trimmed = newName.trim()
9
+ const existing = core.speakers.all.get(speakerId)
10
+ if (!existing || !trimmed || trimmed === existing.name) return
11
+ core.speakers.update(speakerId, { name: trimmed })
12
+ }
@@ -0,0 +1,6 @@
1
+ export function speakerEquals(
2
+ a: { name: string; color?: string },
3
+ b: { name: string; color?: string },
4
+ ): boolean {
5
+ return a.name === b.name && a.color === b.color
6
+ }
@@ -0,0 +1,17 @@
1
+ import type { Core } from "../types"
2
+
3
+ /** Point a turn at another speaker, in every translation that carries the
4
+ * turn (translations of a channel share turn ids). */
5
+ export function switchTurnSpeaker(
6
+ core: Core,
7
+ turnId: string,
8
+ newSpeakerId: string,
9
+ ): void {
10
+ for (const channel of core.channels.values()) {
11
+ for (const translation of channel.translations.values()) {
12
+ const turn = translation.getTurn(turnId)
13
+ if (!turn || turn.speakerId === newSpeakerId) continue
14
+ translation.updateTurn(turnId, { speakerId: newSpeakerId })
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { countTurnsForSpeaker } from "../countTurnsForSpeaker"
3
+ import { makeTestCore } from "./makeTestCore"
4
+
5
+ describe("countTurnsForSpeaker", () => {
6
+ it("counts the turns assigned to the speaker in the active translation", () => {
7
+ const core = makeTestCore()
8
+ expect(countTurnsForSpeaker(core, "spk-1")).toBe(2)
9
+ expect(countTurnsForSpeaker(core, "spk-2")).toBe(1)
10
+ })
11
+
12
+ it("returns 0 for an unknown speaker", () => {
13
+ const core = makeTestCore()
14
+ expect(countTurnsForSpeaker(core, "spk-404")).toBe(0)
15
+ })
16
+ })
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { createSpeakerAndAssign } from "../createSpeakerAndAssign"
3
+ import { makeTestCore } from "./makeTestCore"
4
+
5
+ describe("createSpeakerAndAssign", () => {
6
+ it("creates the speaker (with a color) and assigns the turn", () => {
7
+ const core = makeTestCore()
8
+ const id = createSpeakerAndAssign(core, "turn-1", "Julie")
9
+ expect(id).not.toBeNull()
10
+ const speaker = core.speakers.all.get(id!)
11
+ expect(speaker?.name).toBe("Julie")
12
+ expect(speaker?.color).toBeTruthy()
13
+ expect(
14
+ core.activeChannel.value?.sourceTranslation.getTurn("turn-1")?.speakerId,
15
+ ).toBe(id)
16
+ })
17
+
18
+ it("returns null on a blank name", () => {
19
+ const core = makeTestCore()
20
+ expect(createSpeakerAndAssign(core, "turn-1", " ")).toBeNull()
21
+ })
22
+ })
@@ -0,0 +1,40 @@
1
+ import { createCore } from "../../createCore"
2
+ import type { Core } from "../../types"
3
+ import type { Turn } from "../../../types/editor"
4
+
5
+ function makeTurn(id: string, speakerId: string | null): Turn {
6
+ return { id, speakerId, text: `text of ${id}`, words: [], language: "fr" }
7
+ }
8
+
9
+ /** A core with one channel, one source translation, three turns and two
10
+ * speakers — the minimal document the speaker helpers operate on. */
11
+ export function makeTestCore(): Core {
12
+ const core = createCore()
13
+ core.setDocument({
14
+ title: "test",
15
+ speakers: new Map([
16
+ ["spk-1", { id: "spk-1", name: "Marie", color: "#111111" }],
17
+ ["spk-2", { id: "spk-2", name: "Thomas", color: "#222222" }],
18
+ ]),
19
+ channels: [
20
+ {
21
+ id: "ch-1",
22
+ name: "channel 1",
23
+ duration: 60,
24
+ translations: [
25
+ {
26
+ id: "tr-1",
27
+ languages: ["fr"],
28
+ isSource: true,
29
+ turns: [
30
+ makeTurn("turn-1", "spk-1"),
31
+ makeTurn("turn-2", "spk-2"),
32
+ makeTurn("turn-3", "spk-1"),
33
+ ],
34
+ },
35
+ ],
36
+ },
37
+ ],
38
+ })
39
+ return core
40
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { mergeSpeakers } from "../mergeSpeakers"
3
+ import { makeTestCore } from "./makeTestCore"
4
+
5
+ describe("mergeSpeakers", () => {
6
+ it("reassigns every turn then removes the merged-away speaker", () => {
7
+ const core = makeTestCore()
8
+ mergeSpeakers(core, "spk-1", "spk-2")
9
+ const turns = core.activeChannel.value!.sourceTranslation.turns.value
10
+ expect(turns.every((t) => t.speakerId === "spk-2")).toBe(true)
11
+ expect(core.speakers.all.has("spk-1")).toBe(false)
12
+ expect(core.speakers.all.has("spk-2")).toBe(true)
13
+ })
14
+
15
+ it("is a no-op on same ids or unknown speakers", () => {
16
+ const core = makeTestCore()
17
+ mergeSpeakers(core, "spk-1", "spk-1")
18
+ mergeSpeakers(core, "spk-404", "spk-2")
19
+ expect(core.speakers.all.has("spk-1")).toBe(true)
20
+ expect(
21
+ core.activeChannel.value?.sourceTranslation.getTurn("turn-1")?.speakerId,
22
+ ).toBe("spk-1")
23
+ })
24
+ })
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { renameSpeaker } from "../renameSpeaker"
3
+ import { makeTestCore } from "./makeTestCore"
4
+
5
+ describe("renameSpeaker", () => {
6
+ it("renames an existing speaker (trimmed)", () => {
7
+ const core = makeTestCore()
8
+ renameSpeaker(core, "spk-1", " Marie Dupont ")
9
+ expect(core.speakers.all.get("spk-1")?.name).toBe("Marie Dupont")
10
+ })
11
+
12
+ it("ignores unknown speakers and empty names", () => {
13
+ const core = makeTestCore()
14
+ renameSpeaker(core, "spk-404", "X")
15
+ renameSpeaker(core, "spk-1", " ")
16
+ expect(core.speakers.all.get("spk-1")?.name).toBe("Marie")
17
+ expect(core.speakers.all.has("spk-404")).toBe(false)
18
+ })
19
+ })
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { switchTurnSpeaker } from "../switchTurnSpeaker"
3
+ import { makeTestCore } from "./makeTestCore"
4
+
5
+ function getTurn(core: ReturnType<typeof makeTestCore>, turnId: string) {
6
+ return core.activeChannel.value?.sourceTranslation.getTurn(turnId)
7
+ }
8
+
9
+ describe("switchTurnSpeaker", () => {
10
+ it("reassigns the turn to the new speaker", () => {
11
+ const core = makeTestCore()
12
+ switchTurnSpeaker(core, "turn-1", "spk-2")
13
+ expect(getTurn(core, "turn-1")?.speakerId).toBe("spk-2")
14
+ })
15
+
16
+ it("leaves other turns untouched", () => {
17
+ const core = makeTestCore()
18
+ switchTurnSpeaker(core, "turn-1", "spk-2")
19
+ expect(getTurn(core, "turn-3")?.speakerId).toBe("spk-1")
20
+ })
21
+
22
+ it("ignores unknown turn ids", () => {
23
+ const core = makeTestCore()
24
+ switchTurnSpeaker(core, "turn-404", "spk-2")
25
+ expect(getTurn(core, "turn-1")?.speakerId).toBe("spk-1")
26
+ })
27
+ })
@@ -0,0 +1,22 @@
1
+ import type { Turn, Word } from "../../types/editor"
2
+ import { findTurnIndex } from "./findTurnIndex"
3
+
4
+ export function updateTurnWords(
5
+ turns: Turn[],
6
+ turnId: string,
7
+ words: Word[],
8
+ ): { turns: Turn[]; updated: Turn } | null {
9
+ const idx = findTurnIndex(turns, turnId)
10
+ if (idx === -1) return null
11
+ const turn = turns[idx]!
12
+ const updated: Turn = {
13
+ ...turn,
14
+ words,
15
+ text: null,
16
+ startTime: words[0]?.startTime ?? turn.startTime,
17
+ endTime: words[words.length - 1]?.endTime ?? turn.endTime,
18
+ }
19
+ const next = turns.slice()
20
+ next[idx] = updated
21
+ return { turns: next, updated }
22
+ }
@@ -0,0 +1,47 @@
1
+ export { createCore } from './createCore'
2
+ export { provideCore, useCore } from './useCore'
3
+ export { CROSS_TRANSLATION_ID } from './stores'
4
+ export type {
5
+ Core,
6
+ CorePlugin,
7
+ CoreEventMap,
8
+ CoreOptions,
9
+ CoreCapabilities,
10
+ TurnEventKey,
11
+ UISlot,
12
+ ReadableTranslation,
13
+ TranslationInfo,
14
+ TurnStore,
15
+ TranslationStore,
16
+ ChannelStore,
17
+ SpeakersStore,
18
+ AudioPluginApi,
19
+ TranscriptionEditorPluginApi,
20
+ TurnLock,
21
+ TurnUpdate,
22
+ WireTurn,
23
+ TurnSplit,
24
+ TurnsMerged,
25
+ TurnDeleted,
26
+ TurnSpeakerUpdated,
27
+ SpeakerRenamed,
28
+ SpeakerReplaced,
29
+ LivePluginApi,
30
+ LivePartialEventData,
31
+ LiveFinalEventData,
32
+ LiveTranslationEventData,
33
+ SubtitlePluginApi,
34
+ WatermarkPluginApi,
35
+ WatermarkToken,
36
+ LLMService,
37
+ LLMServiceInit,
38
+ LLMServiceStatus,
39
+ LLMServiceVersion,
40
+ LLMServiceGeneration,
41
+ LLMServiceGenerationStatus,
42
+ LLMServicesPluginApi,
43
+ ChatRole,
44
+ ChatMessage,
45
+ ChatSession,
46
+ ChatPluginApi,
47
+ } from './types'
@@ -0,0 +1,43 @@
1
+ type EventHandler<EventMap, K extends keyof EventMap> = (
2
+ payload: EventMap[K],
3
+ ) => void
4
+ type AnyHandler = (payload: never) => void
5
+
6
+ export function createEventBus<EventMap>() {
7
+ const listeners = new Map<keyof EventMap, Set<AnyHandler>>()
8
+
9
+ function on<K extends keyof EventMap>(
10
+ event: K,
11
+ handler: EventHandler<EventMap, K>,
12
+ ): () => void {
13
+ let set = listeners.get(event)
14
+ if (!set) {
15
+ set = new Set()
16
+ listeners.set(event, set)
17
+ }
18
+ set.add(handler as AnyHandler)
19
+ return () => off(event, handler)
20
+ }
21
+
22
+ function off<K extends keyof EventMap>(
23
+ event: K,
24
+ handler: EventHandler<EventMap, K>,
25
+ ): void {
26
+ listeners.get(event)?.delete(handler as AnyHandler)
27
+ }
28
+
29
+ function emit<K extends keyof EventMap>(
30
+ event: K,
31
+ payload: EventMap[K],
32
+ ): void {
33
+ listeners.get(event)?.forEach((h) =>
34
+ (h as EventHandler<EventMap, K>)(payload),
35
+ )
36
+ }
37
+
38
+ function clear(): void {
39
+ listeners.clear()
40
+ }
41
+
42
+ return { on, off, emit, clear }
43
+ }
@@ -0,0 +1,100 @@
1
+ import { ref, computed, shallowReactive } from "vue"
2
+ import type { Channel } from "../../types/editor"
3
+ import type {
4
+ ChannelStore,
5
+ CoreEventMap,
6
+ ReadableTranslation,
7
+ TranslationStore,
8
+ } from "../types"
9
+ import { createTranslationStore } from "./translationStore"
10
+ import {
11
+ CROSS_TRANSLATION_ID,
12
+ createCrossTranslationStore,
13
+ } from "./crossTranslationStore"
14
+
15
+ type Emit = <K extends keyof CoreEventMap>(event: K, payload: CoreEventMap[K]) => void
16
+ type On = <K extends keyof CoreEventMap>(
17
+ event: K,
18
+ handler: (payload: CoreEventMap[K]) => void,
19
+ ) => () => void
20
+ type SpeakersEnsure = (speakerId: string | null, name?: string) => void
21
+
22
+ export function createChannelStore(
23
+ channel: Channel,
24
+ emit: Emit,
25
+ on: On,
26
+ speakersEnsure: SpeakersEnsure,
27
+ ): ChannelStore {
28
+ const { id, name, description, duration } = channel
29
+
30
+ const translations = shallowReactive(new Map<string, TranslationStore>())
31
+ let sourceTranslation!: TranslationStore
32
+
33
+ for (const tr of channel.translations) {
34
+ const store = createTranslationStore(tr, emit, speakersEnsure)
35
+ translations.set(tr.id, store)
36
+ if (tr.isSource && !sourceTranslation) sourceTranslation = store
37
+ }
38
+
39
+ if (!sourceTranslation) {
40
+ sourceTranslation = translations.values().next().value!
41
+ }
42
+
43
+ const crossTranslation = createCrossTranslationStore(
44
+ sourceTranslation,
45
+ translations,
46
+ emit,
47
+ on,
48
+ )
49
+
50
+ const selectableTranslations: ReadableTranslation[] = [...translations.values()]
51
+ if (crossTranslation) selectableTranslations.push(crossTranslation)
52
+
53
+ const activeTranslationId = ref<string | null>(null)
54
+ const isLoadingHistory = ref(false)
55
+ const hasMoreHistory = ref(true)
56
+
57
+ const activeTranslation = computed<ReadableTranslation>(() => {
58
+ const activeId = activeTranslationId.value
59
+ if (activeId === CROSS_TRANSLATION_ID) return crossTranslation ?? sourceTranslation
60
+ if (activeId) return translations.get(activeId) ?? sourceTranslation
61
+ return sourceTranslation
62
+ })
63
+
64
+ function setActiveTranslation(translationId: string | null): void {
65
+ const normalized = translationId === sourceTranslation.id ? null : translationId
66
+ if (normalized === activeTranslationId.value) return
67
+ activeTranslationId.value = normalized
68
+ emit("translation:change", { translationId: activeTranslation.value.id })
69
+ }
70
+
71
+ function reset(): void {
72
+ for (const translation of translations.values()) {
73
+ translation.setTurns([])
74
+ }
75
+ isLoadingHistory.value = false
76
+ hasMoreHistory.value = true
77
+ emit("channel:reset", { channelId: id })
78
+ }
79
+
80
+ function dispose(): void {
81
+ crossTranslation?.dispose()
82
+ }
83
+
84
+ return {
85
+ id,
86
+ name,
87
+ description,
88
+ duration,
89
+ translations,
90
+ sourceTranslation,
91
+ crossTranslation,
92
+ selectableTranslations,
93
+ activeTranslation,
94
+ isLoadingHistory,
95
+ hasMoreHistory,
96
+ setActiveTranslation,
97
+ reset,
98
+ dispose,
99
+ }
100
+ }
@@ -0,0 +1,141 @@
1
+ import { computed } from "vue"
2
+ import type { Turn } from "../../types/editor"
3
+ import type {
4
+ CoreEventMap,
5
+ ReadableTranslation,
6
+ TranslationStore,
7
+ } from "../types"
8
+ import { extractLangCode } from "../../utils/extractLangCode"
9
+ import { isSameLanguage } from "../../utils/isSameLanguage"
10
+
11
+ export const CROSS_TRANSLATION_ID = "cross"
12
+
13
+ type Emit = <K extends keyof CoreEventMap>(
14
+ event: K,
15
+ payload: CoreEventMap[K],
16
+ ) => void
17
+ type On = <K extends keyof CoreEventMap>(
18
+ event: K,
19
+ handler: (payload: CoreEventMap[K]) => void,
20
+ ) => () => void
21
+
22
+ export interface CrossTranslationStore extends ReadableTranslation {
23
+ /** Unsubscribe the underlying turn-event relay. */
24
+ dispose(): void
25
+ }
26
+
27
+ /**
28
+ * Virtual, read-only translation for a bilingual document: each turn is shown
29
+ * in the *other* language. Returns null when not applicable — i.e. the source
30
+ * doesn't have exactly two languages, or a translation track is missing for one
31
+ * of them. Otherwise turn content is composed from the two matching translation
32
+ * tracks (keyed by turnId). Stores nothing — `turns` is a computed.
33
+ *
34
+ * Because the active-translation event scope filters on `translationId`, and the
35
+ * real tracks emit under their own id, this store relays their turn events under
36
+ * `CROSS_TRANSLATION_ID` so that subtitle/scroller listeners fire while cross is
37
+ * active. Call `dispose()` to detach the relay.
38
+ */
39
+ export function createCrossTranslationStore(
40
+ source: TranslationStore,
41
+ translations: Map<string, TranslationStore>,
42
+ emit: Emit,
43
+ on: On,
44
+ ): CrossTranslationStore | null {
45
+ const langs = source.languages.map(extractLangCode)
46
+ if (langs.length !== 2) return null
47
+ const tracksByLanguage = new Map<string, TranslationStore>()
48
+
49
+ for (const tr of translations.values()) {
50
+ if (tr.id === source.id) {
51
+ continue
52
+ }
53
+ if (tr.languages.length !== 1) {
54
+ return null
55
+ }
56
+ if (!tr.languages[0]) {
57
+ return null
58
+ }
59
+
60
+ tracksByLanguage.set(extractLangCode(tr.languages[0]), tr)
61
+ }
62
+
63
+ for (const lang of langs) {
64
+ if (!tracksByLanguage.has(lang)) {
65
+ return null
66
+ }
67
+ }
68
+
69
+ const [langA, langB] = langs
70
+ if (!langA || !langB) return null
71
+
72
+ // The two translation tracks the cross composes from (excludes any extra
73
+ // language track). Both tracks hold every turn — one in each language — so
74
+ // membership alone isn't enough to pick a side (see relay below).
75
+ const crossTrackIds = new Set([
76
+ tracksByLanguage.get(langA)!.id,
77
+ tracksByLanguage.get(langB)!.id,
78
+ ])
79
+
80
+ const turns = computed<Turn[]>(() =>
81
+ source.turns.value.map((turn) => getTurn(turn.id) ?? turn),
82
+ )
83
+
84
+ function getTurn(turnId: string): Turn | undefined {
85
+ const sourceTurn = source.getTurn(turnId)
86
+ if (!sourceTurn) return undefined
87
+
88
+ const target = isSameLanguage(sourceTurn.language, langA) ? langB : langA
89
+ if (!target) return sourceTurn
90
+
91
+ const targetTurn = tracksByLanguage.get(target)?.getTurn(turnId)
92
+ if (!targetTurn) return sourceTurn
93
+
94
+ return targetTurn
95
+ }
96
+
97
+ // ── Relay underlying track events under the cross id ──────────────────
98
+ // The active-translation scope filters on translationId, and the real tracks
99
+ // emit under their own id, so we re-emit as "cross". Both tracks hold every
100
+ // turn, so we relay only the side opposite the original — i.e. where the
101
+ // turn's language differs from its sourceLanguage. This is data-driven, so it
102
+ // doesn't depend on the source/translation event ordering.
103
+ const unsubs: Array<() => void> = []
104
+
105
+ function isOppositeSide(turn: Turn, translationId: string): boolean {
106
+ if (!crossTrackIds.has(translationId)) return false
107
+ if (turn.sourceLanguage == null) return true
108
+ return !isSameLanguage(turn.language, turn.sourceLanguage)
109
+ }
110
+
111
+ function relayAddOrUpdate(event: "turn:add" | "turn:update"): void {
112
+ unsubs.push(
113
+ on(event, ({ turn, translationId }) => {
114
+ if (!isOppositeSide(turn, translationId)) return
115
+ emit(event, { turn, translationId: CROSS_TRANSLATION_ID })
116
+ }),
117
+ )
118
+ }
119
+ relayAddOrUpdate("turn:add")
120
+ relayAddOrUpdate("turn:update")
121
+ unsubs.push(
122
+ on("turn:remove", ({ turnId, translationId }) => {
123
+ if (!crossTrackIds.has(translationId)) return
124
+ emit("turn:remove", { turnId, translationId: CROSS_TRANSLATION_ID })
125
+ }),
126
+ )
127
+
128
+ function dispose(): void {
129
+ unsubs.forEach((fn) => fn())
130
+ unsubs.length = 0
131
+ }
132
+
133
+ return {
134
+ id: CROSS_TRANSLATION_ID,
135
+ isSource: false,
136
+ languages: source.languages,
137
+ turns,
138
+ getTurn,
139
+ dispose,
140
+ }
141
+ }
@@ -0,0 +1,7 @@
1
+ export { createTranslationStore } from "./translationStore"
2
+ export {
3
+ createCrossTranslationStore,
4
+ CROSS_TRANSLATION_ID,
5
+ } from "./crossTranslationStore"
6
+ export { createChannelStore } from "./channelStore"
7
+ export { createSpeakersStore } from "./speakersStore"
@@ -0,0 +1,53 @@
1
+ import { shallowReactive } from "vue"
2
+ import type { Speaker } from "../../types/editor"
3
+ import type { CoreEventMap, SpeakersStore } from "../types"
4
+ import { ensureSpeaker } from "../helpers/ensureSpeaker"
5
+ import { speakerEquals } from "../helpers/speakerEquals"
6
+
7
+ type Emit = <K extends keyof CoreEventMap>(event: K, payload: CoreEventMap[K]) => void
8
+
9
+ export function createSpeakersStore(
10
+ emit: Emit,
11
+ ): SpeakersStore & { clear(): void } {
12
+ const all = shallowReactive(new Map<string, Speaker>())
13
+
14
+ function ensure(speakerId: string | null, name?: string): void {
15
+ const speaker = ensureSpeaker(all, speakerId, name)
16
+ if (!speaker) return
17
+ all.set(speaker.id, speaker)
18
+ emit("speaker:add", { speaker })
19
+ }
20
+
21
+ function update(speakerId: string, patch: Partial<Omit<Speaker, "id">>): void {
22
+ const existing = all.get(speakerId)
23
+ if (!existing) return
24
+ const updated = { ...existing, ...patch }
25
+ if (speakerEquals(existing, updated)) return
26
+ all.set(speakerId, updated)
27
+ emit("speaker:update", { speaker: updated })
28
+ }
29
+
30
+ function updateOrCreate(speaker: Speaker): void {
31
+ const existing = all.get(speaker.id)
32
+ if (existing) {
33
+ if (speakerEquals(existing, speaker)) return
34
+ all.set(speaker.id, speaker)
35
+ emit("speaker:update", { speaker })
36
+ } else {
37
+ all.set(speaker.id, speaker)
38
+ emit("speaker:add", { speaker })
39
+ }
40
+ }
41
+
42
+ function remove(speakerId: string): void {
43
+ if (!all.has(speakerId)) return
44
+ all.delete(speakerId)
45
+ emit("speaker:remove", { speakerId })
46
+ }
47
+
48
+ function clear(): void {
49
+ all.clear()
50
+ }
51
+
52
+ return { all, ensure, update, updateOrCreate, delete: remove, clear }
53
+ }