feat(playback): click transcript to seek + highlight/scroll active line; Obsidian export button (FR-REC-5, FR-STORE-4)

This commit is contained in:
iamdoubz committed 2026-07-12 09:31:14 -05:00
1 parent 2389fb05a5
commit d5aef2966f
1 file changed
+88 -3
+88 -3
View File
@@ -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<HTMLElement | null>(null);
let lastManualScroll = 0;
$effect(() => {
const id = activeSegId;
if (id == null || !transcriptEl) return;
if (Date.now() - lastManualScroll < 4000) return;
transcriptEl
.querySelector<HTMLElement>(".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}
>
<div class="pane transcript" class:collapsed={layout.transcriptCollapsed}>
<div
class="pane transcript"
class:collapsed={layout.transcriptCollapsed}
bind:this={transcriptEl}
onwheel={() => (lastManualScroll = Date.now())}
ontouchmove={() => (lastManualScroll = Date.now())}
>
{#if layout.transcriptCollapsed}
<button
class="pane-toggle"
@@ -277,11 +325,17 @@
<p class="muted">No transcript for this meeting.</p>
{:else}
{#each m.segments as s (s.id)}
<p>
<button
type="button"
class="seg"
class:active={s.id === activeSegId}
onclick={() => player.seek(s.start_ms)}
title="Play from here"
>
<span class="ts">{fmtTs(s.start_ms)}</span>
<strong>{speakerName(s.speaker, m.speakers)}:</strong>
{s.text}
</p>
</button>
{/each}
{/if}
{/if}
@@ -379,6 +433,13 @@
<FolderOutput size={13} aria-hidden="true" />
Bundle
</button>
<button
onclick={exportObsidian}
title="Export one Obsidian note (notes + summary + transcript, no audio)"
>
<NotebookPen size={13} aria-hidden="true" />
Obsidian
</button>
</div>
<div class="editor-preview">
{#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 {