From d5aef2966fb04eb948d1d92ef28b030f19c0936d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 09:31:14 -0500 Subject: [PATCH] feat(playback): click transcript to seek + highlight/scroll active line; Obsidian export button (FR-REC-5, FR-STORE-4) --- src/lib/views/TranscriptNotes.svelte | 91 +++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/src/lib/views/TranscriptNotes.svelte b/src/lib/views/TranscriptNotes.svelte index a3cc08a..25b0066 100644 --- a/src/lib/views/TranscriptNotes.svelte +++ b/src/lib/views/TranscriptNotes.svelte @@ -5,6 +5,7 @@ import { recording } from "../stores/recording.svelte"; import { meetings } from "../stores/meetings.svelte"; import { settings } from "../stores/settings.svelte"; + import { player } from "../stores/player.svelte"; import { api, type SpeakerInfo } from "../api"; import { renderMarkdown } from "../markdown"; import { save, open } from "@tauri-apps/plugin-dialog"; @@ -193,6 +194,47 @@ if (path) await api.exportMeeting(m.id, path, "docx"); } + // Export a single self-contained Obsidian note (frontmatter + notes + summary + // + action items + timestamped transcript, no audio) — save it into a vault + // folder from the dialog. FR-STORE-4 sibling of the bundle export. + async function exportObsidian() { + const m = meetings.selected; + if (!m) return; + const path = await save({ + defaultPath: `${m.title}.md`, + filters: [{ name: "Obsidian note", extensions: ["md"] }], + }); + if (path) await api.exportMeeting(m.id, path, "obsidian"); + } + + // FR-REC-5: the finalized segment currently playing (greatest start_ms at or + // before the playhead), for highlight + auto-scroll. currentMs ticks ~4x/s + // but this only changes value at a segment boundary, so the effect below is + // quiet between boundaries. + const activeSegId = $derived.by(() => { + const ms = player.currentMs; + let id: number | null = null; + for (const s of meetings.selected?.segments ?? []) { + if (s.start_ms <= ms) id = s.id; + else break; + } + return id; + }); + + // Auto-scroll the playing segment into view — unless the user scrolled the + // transcript by hand recently, so playback doesn't yank them back. + // ponytail: 4s manual-scroll grace; widen if it still feels grabby. + let transcriptEl = $state(null); + let lastManualScroll = 0; + $effect(() => { + const id = activeSegId; + if (id == null || !transcriptEl) return; + if (Date.now() - lastManualScroll < 4000) return; + transcriptEl + .querySelector(".seg.active") + ?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }); + let reprocessModel = $state(""); // T8.7/FR-TRX-4: "" reuses the meeting's current language (backend default // when `language` is omitted) rather than resetting it to auto. @@ -224,7 +266,13 @@ style="grid-template-columns: {splitColumns()};" bind:clientWidth={splitWidth} > -
+
(lastManualScroll = Date.now())} + ontouchmove={() => (lastManualScroll = Date.now())} + > {#if layout.transcriptCollapsed} {/each} {/if} {/if} @@ -379,6 +433,13 @@
{#if notesPreview} @@ -577,6 +638,30 @@ .note-badge { margin-left: 0.3rem; } + /* Finalized-transcript line: a click-to-seek button styled to read as plain + transcript text, highlighted while it's the segment currently playing. */ + .seg { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + border-left: 2px solid transparent; + color: inherit; + font: inherit; + line-height: 1.5; + padding: 0.15rem 0.4rem; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color 120ms ease-out; + } + .seg:hover { + background: var(--bg-hover); + } + .seg.active { + background: var(--accent-soft); + border-left-color: var(--accent); + } /* Transcript timestamp: a quiet monospace prefix, not competing with the speaker name or text for attention. */ .ts {