Files
silo-server/web/src/player/components/MarkerEditPanel.tsx
T
0163df3683 [codex] Add IntroDB marker integration and dialogue-aware Chromaprint refinement (#57)
* docs(markers): design + implementation plans for multi-source markers & TheIntroDB contribution

* fix(markers): TheIntroDB read-path correctness (TVDB, real confidence, best candidate)

Honor TVDB ids in /media lookups (previously dropped — anime/TheTVDB-first
libraries got no markers), decode and use the real per-segment confidence and
submission_count instead of a hardcoded 0.9, and pick the most-submitted /
highest-confidence candidate when several are returned. Adds httptest coverage
for the introdb client and provider.

Phase 1 of docs/superpowers/plans/2026-06-06-marker-sources-and-contribution-implementation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(markers): multi-source dispatch, per-provider config, per-segment provenance

Add marker_provider_config (per-provider fetch enable/priority + contribute
gates, contribution off by default) and a cached ProviderConfigStore. Add
Registry.FetchMerged: query all fetch-enabled providers concurrently and keep
the best candidate per segment (submission_count, then confidence, then fetch
priority), stamping each winning marker with its provider/algorithm. Thread
per-segment provenance through MarkerUpdatePayload and scanner.MarkerUpdate
(additive SegmentProvenance overrides) so a merged result writes correct
per-segment provider/confidence/algorithm; the legacy shared columns keep a
summary. The lazy-playback path now uses FetchMerged. With only TheIntroDB
enabled, behavior is unchanged.

Phase 2 of docs/superpowers/plans/2026-06-06-marker-sources-and-contribution-implementation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(markers): TheIntroDB submission client, contribution audit, service engine

Add a markers.Submitter capability and implement it on the introdb provider
(POST /v3/submit, GET /v3/user/stats; key required, usage-limit aware, applies
the null start/end conventions). Add the marker_contributions audit table and a
value-hash-keyed ContributionStore for idempotency. Add ContributionService:
resolves enabled submitter providers, gates eligibility (never re-submit
online-sourced markers; auto runs require contribute_auto_local + scanner-intro
above the per-provider confidence threshold), checks idempotency, submits, and
records. Wired in main.go; no trigger yet (admin API and task follow).

Phase 3 of docs/superpowers/plans/2026-06-06-marker-sources-and-contribution-implementation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): admin marker editing, contribution, and provider config endpoints

Add the RequireAdmin marker API: GET/PUT /admin/files/{id}/markers (read with
provenance; manual upsert where a segment object sets and null clears),
DELETE .../markers/{segment}, POST .../contribute and GET .../contributions,
plus GET/PUT /admin/markers/providers[/{provider}] and a
.../validate key-check returning user stats. Manual writes go through the
priority-gated UpsertMarkers (source=manual) and notify live sessions; a new
FileRepository.ClearMarkers nulls a segment's columns. Validation mirrors the
contribution rules.

Phase 4 of docs/superpowers/plans/2026-06-06-marker-sources-and-contribution-implementation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(markers): daily auto-contribution task for local intro markers

Add ContributeMarkersTask (daily 04:00, after local detection): when a provider
has contribute_enabled + contribute_auto_local, page through episode files with
a scanner intro marker at/above the provider's confidence threshold (new
ContributionStore.CandidateLocalIntroFiles keyset query) and run them through
ContributionService with Auto=true. No-op when no provider opts in; idempotent
and resumable across runs.

Phase 5 of docs/superpowers/plans/2026-06-06-marker-sources-and-contribution-implementation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(intromarkers): refine chromaprint starts with dialogue cues

* feat(markers): finish marker management backend

* feat(web): add marker editing UI

* feat(markers): use plugin marker providers

* fix(markers): address PR review feedback

* feat(player): show marker labels on seek hover

* fix(markers): type nullable marker mutation params

* feat(markers): audit marker edits and add permission

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:29:26 -04:00

252 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { Check, GripHorizontal, RotateCcw, Trash2, X } from "lucide-react";
import type { MarkerEditor } from "../hooks/useMarkerEditor";
import { MARKER_KINDS, MARKER_LABELS } from "../hooks/useMarkerEditor";
import type { MarkerKind } from "../types";
import { formatTime } from "./SeekBar";
interface MarkerEditPanelProps {
editor: MarkerEditor;
currentTime: number;
}
/** Solid accent per kind, matching the seek-bar region tints. */
const DOT_COLORS: Record<MarkerKind, string> = {
intro: "bg-sky-400",
recap: "bg-violet-400",
credits: "bg-amber-400",
preview: "bg-emerald-400",
};
/**
* Floating panel for editing intro/recap/credits/preview markers from the
* player. The selected segment expands to reveal its controls (and is the one
* whose draggable handles show on the seek bar); the others stay collapsed to
* a single line so the panel reads cleanly.
*/
export function MarkerEditPanel({ editor, currentTime }: MarkerEditPanelProps) {
const { draft, activeKind, saving, error, dirty } = editor;
const panelRef = useRef<HTMLDivElement>(null);
const dragCleanupRef = useRef<(() => void) | null>(null);
const [offset, setOffset] = useState({ x: 0, y: 0 });
useEffect(() => {
return () => {
dragCleanupRef.current?.();
};
}, []);
// Drag the panel by its header. The anchored position is shifted with a
// transform and clamped to the player bounds so it can't leave the frame.
const handleDragStart = (e: React.PointerEvent) => {
e.preventDefault();
dragCleanupRef.current?.();
const startX = e.clientX;
const startY = e.clientY;
const base = offset;
const onMove = (ev: PointerEvent) => {
const panel = panelRef.current;
let nx = base.x + (ev.clientX - startX);
let ny = base.y + (ev.clientY - startY);
const parent = panel?.offsetParent as HTMLElement | null;
if (panel && parent) {
const margin = 8;
// offsetLeft/offsetTop are the layout (untransformed) position, so the
// clamp range stays stable across repeated drags.
nx = Math.min(
parent.clientWidth - panel.offsetWidth - margin - panel.offsetLeft,
Math.max(margin - panel.offsetLeft, nx),
);
ny = Math.min(
parent.clientHeight - panel.offsetHeight - margin - panel.offsetTop,
Math.max(margin - panel.offsetTop, ny),
);
}
setOffset({ x: nx, y: ny });
};
const onUp = () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
dragCleanupRef.current = null;
};
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
dragCleanupRef.current = onUp;
};
return (
<div
ref={panelRef}
className="absolute bottom-40 left-4 z-50 w-[22rem] overflow-hidden rounded-2xl border border-white/10 bg-neutral-900/95 text-white shadow-2xl backdrop-blur-xl sm:bottom-44 sm:left-6"
style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
onClick={(e) => e.stopPropagation()}
>
{/* Header — drag handle */}
<div
className="flex cursor-move touch-none items-start justify-between gap-3 px-4 pt-3.5 pb-3 select-none"
onPointerDown={handleDragStart}
>
<div className="flex items-start gap-2">
<GripHorizontal className="mt-0.5 h-4 w-4 shrink-0 text-white/30" />
<div className="flex flex-col gap-0.5">
<span className="text-sm font-semibold tracking-tight">Edit markers</span>
<span className="text-[11px] leading-tight text-white/40">
Drag the timeline handles, or set points to the playhead.
</span>
</div>
</div>
<button
type="button"
aria-label="Close marker editor"
className="-mr-1 rounded-md p-1 text-white/50 transition-colors hover:bg-white/10 hover:text-white"
onPointerDown={(e) => e.stopPropagation()}
onClick={editor.cancel}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Segments */}
<div className="flex flex-col gap-1 px-2 pb-2">
{MARKER_KINDS.map((kind) => {
const range = draft[kind];
const isActive = kind === activeKind;
return (
<div
key={kind}
className={[
"rounded-xl px-2.5 transition-colors",
isActive ? "bg-white/[0.06] py-2 ring-1 ring-white/10" : "py-1",
].join(" ")}
>
<button
type="button"
onClick={() => editor.selectKind(kind)}
className="flex w-full items-center gap-2.5 rounded-md py-1 text-left"
>
<span
className={[
"shrink-0 rounded-full transition-all",
DOT_COLORS[kind],
isActive ? "h-2.5 w-2.5 ring-2 ring-white/25" : "h-2 w-2 opacity-50",
].join(" ")}
/>
<span
className={[
"text-[13px] font-medium transition-colors",
isActive ? "text-white" : "text-white/70",
].join(" ")}
>
{MARKER_LABELS[kind]}
</span>
<span
className={[
"ml-auto font-mono text-[11px] tabular-nums",
range ? "text-white/75" : "text-white/35",
].join(" ")}
>
{range ? `${formatTime(range.start)} – ${formatTime(range.end)}` : "Not set"}
</span>
</button>
{isActive && (
<div className="mt-2 flex items-center gap-1.5">
<EdgeButton onClick={() => editor.setEdge(kind, "start", currentTime)}>
Set start
</EdgeButton>
<EdgeButton onClick={() => editor.setEdge(kind, "end", currentTime)}>
Set end
</EdgeButton>
<div className="ml-auto flex items-center gap-0.5">
<button
type="button"
aria-label={`Reset ${MARKER_LABELS[kind]} marker`}
title="Reset to saved"
disabled={!editor.isKindDirty(kind)}
onClick={() => editor.resetKind(kind)}
className="rounded-md p-1.5 text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-25"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`Clear ${MARKER_LABELS[kind]} marker`}
title="Clear marker"
disabled={!range}
onClick={() => editor.clearKind(kind)}
className="rounded-md p-1.5 text-white/40 transition-colors hover:bg-white/10 hover:text-red-300 disabled:pointer-events-none disabled:opacity-25"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
</div>
);
})}
</div>
{error && (
<div className="mx-4 mb-2 rounded-md bg-red-500/15 px-2.5 py-1.5 text-[11px] text-red-300">
{error}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between gap-3 border-t border-white/[0.06] bg-black/20 px-4 py-3">
<span className="shrink-0 font-mono text-[11px] whitespace-nowrap text-white/40 tabular-nums">
{formatTime(currentTime)}
</span>
<div className="flex shrink-0 items-center gap-1.5">
{dirty && (
<button
type="button"
onClick={editor.resetAll}
title="Reset all markers to saved"
className="flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-xs font-medium whitespace-nowrap text-white/60 transition-colors hover:bg-white/10 hover:text-white"
>
<RotateCcw className="h-3.5 w-3.5" />
Reset all
</button>
)}
<button
type="button"
onClick={editor.cancel}
className="rounded-lg px-3 py-1.5 text-xs font-medium whitespace-nowrap text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
Cancel
</button>
<button
type="button"
disabled={!dirty || saving}
onClick={() => {
void editor.save();
}}
className="flex items-center gap-1.5 rounded-lg bg-white px-3.5 py-1.5 text-xs font-semibold whitespace-nowrap text-neutral-900 transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{saving ? (
<span className="h-3 w-3 animate-spin rounded-full border-2 border-neutral-900/30 border-t-neutral-900" />
) : (
<Check className="h-3.5 w-3.5" />
)}
Save
</button>
</div>
</div>
</div>
);
}
function EdgeButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className="rounded-md border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[11px] font-medium text-white/85 transition-colors hover:border-white/20 hover:bg-white/10 hover:text-white active:bg-white/[0.16]"
>
{children}
</button>
);
}