Files
Lumotia/src/routes/viewer/+page.svelte
Jake 07925cf360 feat(ux): PR 1.3 — replace 0→100 hover-reveals with calm baseline
Affordances on MicroSteps rows (thumbs/edit/speaker/just-start) and
transcript-viewer segments (star, copy, delete) used opacity-0 →
opacity-100 on group-hover, which (a) hid them from keyboard users
until focus and (b) violated the "always-visible affordance" line in
the Kon design audit.

Replace with opacity-30 baseline + group-hover:opacity-100 +
focus-visible:opacity-100 (MicroSteps) / focus-within:opacity-100
(viewer, since the actions sit inside an interactive segment row).
Result: affordances are calm but visible at rest, lift on hover or
keyboard focus, never invisible.

HistoryPage was checked — its row checkbox at line 757 is already
opacity-50 → opacity-100 on hover, the pattern this PR is moving
toward, so no change needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:20:34 +01:00

607 lines
22 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, tick } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import type { TranscriptDto, TranscriptEntry, ViewerSegment } from "$lib/types/app";
import SpeakerButton from "$lib/components/SpeakerButton.svelte";
import { formatTime, formatDuration } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
import { errorMessage } from "$lib/utils/errors.js";
import { parseStoredJson } from "$lib/utils/storage.js";
import { mapTranscriptRow, saveTranscriptMeta } from "$lib/stores/page.svelte.js";
import { toasts } from "$lib/stores/toasts.svelte.ts";
import { DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts";
let item = $state<TranscriptEntry | null>(null);
let audioEl = $state<HTMLAudioElement | null>(null);
let playing = $state(false);
let currentTime = $state(0);
let duration = $state(0);
let playbackRate = $state(1);
let searchQuery = $state("");
let activeSegmentIdx = $state(-1);
let editingIdx = $state(-1);
let editingText = $state("");
let showTimestamps = $state(true);
let showStarredOnly = $state(false);
let animFrameId: number | null = null;
let segmentRefs = $state<Array<HTMLElement | null>>([]);
let viewerMode = $state<"view" | "edit">("view");
let textDraft = $state("");
let textDirty = $state(false);
let textSaveTimer: ReturnType<typeof setTimeout> | null = null;
let editingTextareaEl = $state<HTMLTextAreaElement | null>(null);
let textLearnBase = $state("");
function stopAudio() {
if (audioEl) audioEl.pause();
playing = false;
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
animFrameId = null;
}
function buildAudio(audioPath: string | null) {
stopAudio();
audioEl = null;
duration = 0;
currentTime = 0;
if (!audioPath) return;
const src = convertFileSrc(audioPath);
const audio = new Audio(src);
audio.playbackRate = playbackRate;
audio.onloadedmetadata = () => { duration = audio.duration; };
audio.onended = () => {
playing = false;
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
animFrameId = null;
};
audio.onerror = () => { playing = false; };
audioEl = audio;
}
function loadViewerItem(nextItem: TranscriptEntry | null) {
item = nextItem;
textDraft = nextItem?.text ?? "";
textLearnBase = nextItem?.text ?? "";
activeSegmentIdx = -1;
editingIdx = -1;
editingText = "";
segmentRefs.length = 0;
buildAudio(nextItem?.audioPath ?? null);
}
// Hydrate the viewer from a transcript ID handed off via localStorage.
// The full row is fetched from SQLite via `get_transcript` so transcript
// text never lives in localStorage where any same-origin script could
// read it.
async function loadFromHandoff(raw: string | null) {
const handoff = parseStoredJson<{ id?: unknown }>(raw);
const id = handoff?.id != null ? String(handoff.id) : "";
if (!id) {
loadViewerItem(null);
return;
}
try {
const dto = await invoke<TranscriptDto | null>("get_transcript", { id });
loadViewerItem(dto ? mapTranscriptRow(dto) : null);
} catch (err) {
console.warn("viewer get_transcript failed", errorMessage(err));
loadViewerItem(null);
}
}
onMount(() => {
loadFromHandoff(localStorage.getItem("kon_viewer_item"));
const mode = localStorage.getItem("kon_viewer_mode");
if (mode === "edit" || mode === "view") viewerMode = mode;
// Listen for new items via storage events
window.addEventListener("storage", handleStorageChange);
});
onDestroy(() => {
stopAudio();
window.removeEventListener("storage", handleStorageChange);
// Flush any pending text edit before the window tears down.
if (textSaveTimer) {
clearTimeout(textSaveTimer);
textSaveTimer = null;
}
if (viewerMode === "edit") {
commitTextEdit(true);
} else if (textDirty) {
commitTextEdit();
}
});
async function maybeLearnCorrections(originalText: string, editedText: string) {
if (!item || !originalText.trim() || !editedText.trim() || originalText === editedText) {
return;
}
try {
const learned = await invoke<Array<{ term: string }>>("learn_profile_terms_from_edit_cmd", {
profileId: item.profileId || DEFAULT_PROFILE_ID,
originalText,
editedText,
});
if (learned.length > 0) {
const count = learned.length;
toasts.success(
count === 1 ? "Learned 1 profile term" : `Learned ${count} profile terms`,
learned.map((entry) => entry.term).join(", "),
);
}
} catch (err) {
console.warn("viewer maybeLearnCorrections failed", errorMessage(err));
}
}
function handleStorageChange(e: StorageEvent) {
if (e.key === "kon_viewer_item" && e.newValue) {
void loadFromHandoff(e.newValue);
}
}
function togglePlay() {
if (!audioEl) return;
if (playing) {
audioEl.pause();
playing = false;
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
} else {
audioEl.play();
playing = true;
tickTime();
}
}
function tickTime() {
if (audioEl) {
currentTime = audioEl.currentTime;
updateActiveSegment();
if (!audioEl.paused) {
animFrameId = requestAnimationFrame(tickTime);
}
}
}
function updateActiveSegment() {
if (!item?.segments) return;
const t = currentTime;
for (let i = 0; i < item.segments.length; i++) {
const seg = item.segments[i];
if (t >= seg.start && t < seg.end) {
if (activeSegmentIdx !== i) {
activeSegmentIdx = i;
// Auto-scroll to active segment
segmentRefs[i]?.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
}
}
function seekTo(e: Event) {
const val = parseFloat((e.currentTarget as HTMLInputElement).value);
if (audioEl) {
audioEl.currentTime = val;
currentTime = val;
updateActiveSegment();
}
}
function seekToTime(time: number) {
if (audioEl) {
audioEl.currentTime = time;
currentTime = time;
if (!playing) {
audioEl.play();
playing = true;
tickTime();
}
updateActiveSegment();
}
}
function setSpeed(speed: number) {
playbackRate = speed;
if (audioEl) audioEl.playbackRate = speed;
}
// Search: find matching segments
let matchingSegments = $derived.by(() => {
if (!searchQuery.trim() || !item?.segments) return new Set<number>();
const q = searchQuery.toLowerCase();
const matches = new Set<number>();
item.segments.forEach((seg, i) => {
if (seg.text.toLowerCase().includes(q)) matches.add(i);
});
return matches;
});
let matchCount = $derived.by(() => matchingSegments.size);
// Highlight search matches in text (XSS-safe: escapes HTML entities first)
function highlightText(text: string) {
if (!searchQuery.trim()) return text;
const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const q = searchQuery.trim();
const regex = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
return escaped.replace(regex, '<mark class="bg-accent/25 text-text rounded px-0.5">$1</mark>');
}
// Segment editing
async function startEditing(idx: number) {
if (!item?.segments?.[idx]) return;
editingIdx = idx;
editingText = item.segments[idx].text;
await tick();
editingTextareaEl?.focus();
editingTextareaEl?.select();
}
function finishEditing() {
if (editingIdx >= 0 && item?.segments) {
const previousText = item.text;
item.segments[editingIdx].text = editingText.trim();
// Update full text
item.text = item.segments.map((s) => s.text).join(" ").trim();
textDraft = item.text;
saveItemToHistory();
// Task 2.5 — persist segment boundaries (text + starred flags) to
// SQLite via segments_json. update_transcript above only covers text
// and title; the segment array needs the dedicated meta command.
persistSegments();
void maybeLearnCorrections(previousText, item.text);
textLearnBase = item.text;
}
editingIdx = -1;
editingText = "";
}
function deleteSegment(idx: number) {
if (item?.segments) {
item.segments.splice(idx, 1);
item.text = item.segments.map((s) => s.text).join(" ").trim();
saveItemToHistory();
// Task 2.5 — persist the shrunk segment array.
persistSegments();
if (editingIdx === idx) { editingIdx = -1; }
}
}
function toggleStar(idx: number) {
if (item?.segments) {
item.segments[idx].starred = !item.segments[idx].starred;
// Task 2.5 — persist the updated segments array (incl. starred flags)
// via the new update_transcript_meta_cmd. Before this, starring a
// segment was in-memory only and vanished on reload.
persistSegments();
}
}
// Task 2.5 — single entry point for segments_json persistence. Callers
// already guard with `if (item?.segments)` so the snapshot here is safe;
// the @ts-ignore matches the file-wide pattern where `item` inferred as
// `null` from `$state(null)` narrows to `never` after guards. Replacing
// the annotation needs a full viewer re-typing pass — out of scope for
// Task 2.5, tracked by the file's 80-odd pre-existing TS errors.
function persistSegments() {
if (!item) return;
saveTranscriptMeta(item.id, { segments: item.segments });
}
function copySegment(idx: number) {
if (item?.segments?.[idx]) {
const text = item.segments[idx].text.trim();
navigator.clipboard.writeText(text).catch(() => {
invoke("copy_to_clipboard", { text }).catch(() => {});
});
}
}
// Persist edits back to SQLite. `update_transcript` covers `text` / `title`;
// segment structure, per-segment starred flags, manualTags, template and
// language go through `update_transcript_meta_cmd` (Task 2.5) via the
// store's `saveTranscriptMeta` helper — callers in this file wire those
// up alongside the plain-text update below.
//
// We re-stamp the localStorage handoff with the same ID so a sibling
// viewer window receives a `storage` event and re-fetches from SQLite.
// The handoff intentionally contains no transcript content — see
// `loadFromHandoff` for the rationale.
function saveItemToHistory() {
if (!item) return;
try {
localStorage.setItem(
"kon_viewer_item",
JSON.stringify({ id: String(item.id), stamp: Date.now() }),
);
} catch {}
// Best-effort SQLite persistence. Silently ignored in browser preview
// (no Tauri runtime) — matches the main-window store's behaviour.
invoke("update_transcript", {
id: String(item.id),
text: item.text ?? null,
title: item.title ?? null,
}).catch((err) => {
console.warn("viewer saveItemToHistory: update_transcript failed", errorMessage(err));
});
}
function scheduleTextSave() {
textDirty = true;
if (textSaveTimer) clearTimeout(textSaveTimer);
textSaveTimer = setTimeout(() => {
commitTextEdit();
}, 400);
}
function commitTextEdit(learnCorrections = false) {
if (!item) return;
const next = textDraft;
if (next !== item.text) {
item.text = next;
// The compact preview in History is derived from `preview`; keep it
// roughly in sync so the list does not show stale copy after an edit.
item.preview = next.slice(0, 120);
saveItemToHistory();
}
if (learnCorrections && next !== textLearnBase) {
void maybeLearnCorrections(textLearnBase, next);
textLearnBase = next;
}
if (next === item.text) {
textDirty = false;
return;
}
textDirty = false;
}
// Filtered segments (starred filter + search)
let visibleSegments = $derived.by(() => {
if (!item?.segments) return [] as ViewerSegment[];
let segs: ViewerSegment[] = item.segments.map((seg, i) => ({ ...seg, _idx: i }));
if (showStarredOnly) {
segs = segs.filter((s) => s.starred);
}
return segs;
});
// Window drag — pointerdown + setPointerCapture avoids the mousedown
// latency that makes KWin's initial grab feel draggy.
function handleDragStart(e: PointerEvent) {
if (e.button !== 0) return;
if ((e.target as HTMLElement | null)?.closest("button")) return;
if ((e.target as HTMLElement | null)?.closest("input")) return;
try { (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId); } catch {}
getCurrentWindow().startDragging();
}
</script>
<div class="flex flex-col h-full bg-bg">
<!-- Drag handle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center h-[36px] bg-bg-elevated select-none px-3"
onpointerdown={handleDragStart}
>
<span class="text-[12px] font-medium text-text-secondary tracking-wide">
Kon - {viewerMode === "edit" ? "Editor" : "Viewer"}
</span>
<div class="flex-1"></div>
</div>
{#if item}
<!-- Item info -->
<div class="px-5 pt-3 pb-2 flex items-start gap-2">
<div class="flex-1 min-w-0">
<p class="text-[13px] text-text font-medium">
{item.title || "Transcript"}
</p>
<p class="text-[10px] text-text-tertiary mt-0.5">
{item.date}{#if item.duration} · {formatDuration(item.duration)}{/if} · {item.source}
</p>
</div>
{#if item.text?.trim()}
<SpeakerButton text={item.text} label="Read transcript aloud" size={14} />
{/if}
</div>
<!-- Media controls -->
{#if audioEl}
<div class="flex items-center gap-3 px-5 py-2 border-b border-border-subtle">
<!-- Play/Pause -->
<button
class="w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0
{playing ? 'bg-accent text-white' : 'bg-accent/15 text-accent hover:bg-accent/25'}"
onclick={togglePlay}
aria-label={playing ? "Pause" : "Play"}
>
{#if playing}
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
{:else}
<svg class="w-4 h-4 ml-0.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Time -->
<span class="text-[11px] text-text-tertiary tabular-nums min-w-[70px]">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<!-- Seek bar -->
<input
type="range"
min="0"
max={duration || 0}
step="0.1"
value={currentTime}
oninput={seekTo}
class="flex-1 accent-accent h-1"
data-no-transition
/>
<!-- Speed pills -->
<div class="flex gap-0.5 flex-shrink-0">
{#each PLAYBACK_SPEEDS as speed}
<button
class="text-[9px] px-1.5 py-0.5 rounded-full
{playbackRate === speed
? 'bg-accent/15 text-accent font-medium'
: 'text-text-tertiary hover:text-text-secondary'}"
onclick={() => setSpeed(speed)}
>{speed}x</button>
{/each}
</div>
</div>
{/if}
<!-- Search bar + view controls (segment-specific; hidden in edit mode) -->
{#if viewerMode !== "edit"}
<div class="flex items-center gap-2 px-5 py-2 border-b border-border-subtle">
<div class="flex items-center gap-2 bg-bg-input border border-border rounded-lg px-3 py-1.5 focus-within:border-accent flex-1">
<svg class="w-3.5 h-3.5 text-text-tertiary flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" stroke-linecap="round" />
</svg>
<input
type="text"
class="flex-1 bg-transparent text-[12px] text-text placeholder:text-text-tertiary focus:outline-none"
placeholder="Search transcript..."
bind:value={searchQuery}
data-no-transition
/>
{#if searchQuery}
<span class="text-[10px] text-text-tertiary">{matchCount} matches</span>
<button class="text-[10px] text-text-tertiary hover:text-text" onclick={() => searchQuery = ""}>Clear</button>
{/if}
</div>
<!-- Compact mode toggle -->
<button
class="text-[10px] px-2 py-1 rounded-lg flex-shrink-0
{showTimestamps ? 'text-text-tertiary hover:text-text-secondary' : 'text-accent bg-accent/10'}"
onclick={() => showTimestamps = !showTimestamps}
title={showTimestamps ? "Hide timestamps (compact)" : "Show timestamps"}
>Compact</button>
<!-- Starred filter -->
<button
class="text-[10px] px-2 py-1 rounded-lg flex-shrink-0
{showStarredOnly ? 'text-accent bg-accent/10' : 'text-text-tertiary hover:text-text-secondary'}"
onclick={() => showStarredOnly = !showStarredOnly}
title={showStarredOnly ? "Show all segments" : "Show starred only"}
>Starred</button>
</div>
{/if}
<!-- Transcript segments / editor -->
<div class="flex-1 overflow-y-auto px-5 py-3 min-h-0">
{#if viewerMode === "edit"}
<textarea
class="w-full h-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px]
text-text leading-relaxed resize-none focus:outline-none focus:border-accent"
bind:value={textDraft}
oninput={scheduleTextSave}
onblur={() => commitTextEdit(true)}
data-no-transition
placeholder="Edit the transcript..."
></textarea>
{:else if item.segments && item.segments.length > 0}
<div class="flex flex-col gap-1">
{#each visibleSegments as seg (seg._idx)}
<div
bind:this={segmentRefs[seg._idx]}
class="group flex gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors
{activeSegmentIdx === seg._idx
? 'bg-accent/10 border-l-2 border-accent'
: matchingSegments.has(seg._idx)
? 'bg-warning/5 border-l-2 border-warning/40'
: 'border-l-2 border-transparent hover:bg-hover'}"
onclick={() => seekToTime(seg.start)}
ondblclick={() => startEditing(seg._idx)}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === "Enter" && editingIdx !== seg._idx) seekToTime(seg.start); }}
>
<!-- Star -->
<button
class="mt-0.5 flex-shrink-0 {seg.starred ? 'text-accent' : 'text-text-tertiary opacity-30 group-hover:opacity-100 focus-within:opacity-100'}"
onclick={(e) => { e.stopPropagation(); toggleStar(seg._idx); }}
aria-label={seg.starred ? "Unstar" : "Star"}
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill={seg.starred ? "currentColor" : "none"} stroke="currentColor" stroke-width="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
<!-- Timestamp -->
{#if showTimestamps}
<span class="text-[10px] text-text-tertiary tabular-nums flex-shrink-0 mt-0.5 min-w-[36px]">
{formatTime(seg.start)}
</span>
{/if}
<!-- Text (editable or display) -->
{#if editingIdx === seg._idx}
<textarea
bind:this={editingTextareaEl}
class="flex-1 bg-bg-input border border-accent rounded-lg px-2 py-1 text-[13px] text-text
leading-relaxed resize-none focus:outline-none min-h-[40px]"
bind:value={editingText}
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); finishEditing(); } if (e.key === "Escape") { editingIdx = -1; } }}
onblur={finishEditing}
onclick={(e) => e.stopPropagation()}
data-no-transition
></textarea>
{:else}
<p class="text-[13px] text-text leading-relaxed flex-1">
{#if searchQuery.trim()}
{@html highlightText(seg.text.trim())}
{:else}
{seg.text.trim()}
{/if}
</p>
{/if}
<!-- Actions (hover reveal) -->
<div class="flex items-center gap-1 opacity-30 group-hover:opacity-100 focus-within:opacity-100 flex-shrink-0">
<button
class="text-text-tertiary hover:text-accent"
onclick={(e) => { e.stopPropagation(); copySegment(seg._idx); }}
title="Copy segment"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
<button
class="text-text-tertiary hover:text-danger"
onclick={(e) => { e.stopPropagation(); deleteSegment(seg._idx); }}
title="Delete segment"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
{/each}
</div>
{:else}
<!-- No segments — show full text -->
<p class="text-[13px] text-text leading-relaxed whitespace-pre-wrap">{item.text}</p>
{/if}
</div>
{:else}
<div class="flex-1 flex items-center justify-center">
<p class="text-[13px] text-text-tertiary">No transcript loaded</p>
</div>
{/if}
</div>