feat(playback): click transcript to seek + highlight/scroll active line; Obsidian export button (FR-REC-5, FR-STORE-4)
This commit is contained in:
1 parent
2389fb05a5
commit
d5aef2966f
1 file changed
+88
-3
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user