diff --git a/apps/frontend/src/components/DashboardScreen.tsx b/apps/frontend/src/components/DashboardScreen.tsx
index 17c1be26..7090cd99 100644
--- a/apps/frontend/src/components/DashboardScreen.tsx
+++ b/apps/frontend/src/components/DashboardScreen.tsx
@@ -1,3 +1,4 @@
+import { ControlTooltip } from "@/components/ui/tooltip";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
@@ -546,16 +547,18 @@ export function DashboardVersionBadge({
if (!latestRelease) {
return (
-
- {versionLabel}
-
+
+
+ {versionLabel}
+
+
);
}
@@ -573,7 +576,6 @@ export function DashboardVersionBadge({
"gap-1.5 border-primary/40 bg-primary/10 text-primary transition-colors hover:bg-primary/15 focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:outline-none",
)}
aria-label={`${updateLabel}. View release notes.`}
- title={updateLabel}
data-dashboard-version-badge
data-dashboard-update-available
>
@@ -824,17 +826,18 @@ export default function DashboardScreen({
Sources
{renderViewerFilterPicker()}
-
+
+
+
@@ -855,67 +858,71 @@ export default function DashboardScreen({
Sources
{renderViewerFilterPicker()}
-
-
-
-
-
-
-
-
-
- {!showViewerFilterControl && "Disconnect"}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!showViewerFilterControl && "Disconnect"}
+
+
diff --git a/apps/frontend/src/components/MobilePwaInstallNudge.tsx b/apps/frontend/src/components/MobilePwaInstallNudge.tsx
index 9559ac54..665ef72b 100644
--- a/apps/frontend/src/components/MobilePwaInstallNudge.tsx
+++ b/apps/frontend/src/components/MobilePwaInstallNudge.tsx
@@ -1,3 +1,4 @@
+import { ControlTooltip } from "@/components/ui/tooltip";
import { Download, Share, Smartphone, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
@@ -99,15 +100,16 @@ export function MobilePwaInstallNudgeCard({
: "Open it like an app with a faster, full-screen experience."}
-
+
+
+
diff --git a/apps/frontend/src/components/editor/EditorControls.tsx b/apps/frontend/src/components/editor/EditorControls.tsx
index dae877a4..2c2d4d9a 100644
--- a/apps/frontend/src/components/editor/EditorControls.tsx
+++ b/apps/frontend/src/components/editor/EditorControls.tsx
@@ -1,9 +1,4 @@
-import {
- useMemo,
- type CSSProperties,
- type ReactElement,
- type ReactNode,
-} from "react";
+import { useMemo, type CSSProperties, type ReactNode } from "react";
import {
Camera,
Pause,
@@ -15,11 +10,7 @@ import {
ZoomIn,
ZoomOut,
} from "lucide-react";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+import { ControlTooltip } from "@/components/ui/tooltip";
import {
Drawer,
DrawerContent,
@@ -67,31 +58,6 @@ interface EditorControlsProperties {
onFitSelection: () => void;
}
-function ControlTooltip({
- label,
- disabled = false,
- children,
-}: {
- label: string;
- disabled?: boolean;
- children: ReactElement;
-}) {
- return (
-
-
- {disabled ? (
-
- {children}
-
- ) : (
- children
- )}
-
- {label}
-
- );
-}
-
export function EditorControls({
variant = "desktop",
playbackSourcePanel,
diff --git a/apps/frontend/src/components/editor/EditorExportDialog.tsx b/apps/frontend/src/components/editor/EditorExportDialog.tsx
index 12ec80be..00925f8d 100644
--- a/apps/frontend/src/components/editor/EditorExportDialog.tsx
+++ b/apps/frontend/src/components/editor/EditorExportDialog.tsx
@@ -1,4 +1,5 @@
-import { Download } from "lucide-react";
+import { Download, FileText } from "lucide-react";
+import { BouncyAccordion } from "@/components/ui/bouncy-accordion";
import type {
ExportFormat,
ExportResolution,
@@ -185,20 +186,24 @@ export function EditorExportDialog({
hlsSourceLabel={hlsSourceLabel}
/>
-
-
- Advanced filename settings
-
-
-
-
-
+
,
+ description: (
+
+ ),
+ },
+ ]}
+ />
diff --git a/apps/frontend/src/components/editor/EditorMediaRange.tsx b/apps/frontend/src/components/editor/EditorMediaRange.tsx
index a7d39c13..ce7857dd 100644
--- a/apps/frontend/src/components/editor/EditorMediaRange.tsx
+++ b/apps/frontend/src/components/editor/EditorMediaRange.tsx
@@ -1,3 +1,4 @@
+import { ControlTooltip } from "@/components/ui/tooltip";
import {
RangeScrollbar,
Timeline,
@@ -39,11 +40,12 @@ export function EditorMediaRange({ engine }: { engine: TimelineEngine }) {
className="editor-media-range-row"
style={{ top: rect.y, height: rect.height }}
>
-
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
diff --git a/apps/frontend/src/components/editor/EditorScreen.tsx b/apps/frontend/src/components/editor/EditorScreen.tsx
index c4efebd2..c45b80a1 100644
--- a/apps/frontend/src/components/editor/EditorScreen.tsx
+++ b/apps/frontend/src/components/editor/EditorScreen.tsx
@@ -1,3 +1,4 @@
+import { ConfirmationDialog } from "@/components/ui/confirmation-dialog";
import {
lazy,
Suspense,
@@ -152,6 +153,7 @@ function EditorScreenContent({
const [editorPropertiesOpenSections, setEditorPropertiesOpenSections] =
useState(loadEditorPropertiesOpenSections);
const [exportDialogMounted, setExportDialogMounted] = useState(false);
+ const subtitleTrackTriggerReference = useRef(null);
const {
subtitleTracks,
selectedSubtitleTrack,
@@ -170,6 +172,9 @@ function EditorScreenContent({
clippedSubtitleCues,
subtitleExportSummary,
handleSelectedSubtitleTrackChange,
+ subtitleTrackChangePending,
+ confirmSubtitleTrackChange,
+ cancelSubtitleTrackChange,
selectedSubtitleCue,
handleSelectedSubtitleTextCommit,
handleSelectedSubtitleStartCommit,
@@ -604,6 +609,8 @@ function EditorScreenContent({
providerId={session.source.providerId}
subtitleTracks={subtitleTracks}
selectedSubtitleTrackKey={selectedSubtitleTrackKey}
+ subtitleTrackChangePending={subtitleTrackChangePending}
+ subtitleTrackTriggerRef={subtitleTrackTriggerReference}
onSelectedSubtitleTrackKeyChange={handleSelectedSubtitleTrackChange}
subtitlesEnabled={subtitleEnabled}
onSubtitlesEnabledChange={setSubtitleEnabled}
@@ -649,6 +656,15 @@ function EditorScreenContent({
return (
+
;
onSelectedSubtitleTrackKeyChange: (value: string) => void;
subtitlesEnabled: boolean;
onSubtitlesEnabledChange: (value: boolean) => void;
@@ -85,6 +93,8 @@ export function EditorSubtitlePanel({
providerId,
subtitleTracks,
selectedSubtitleTrackKey,
+ subtitleTrackChangePending,
+ subtitleTrackTriggerRef,
onSelectedSubtitleTrackKeyChange,
subtitlesEnabled,
onSubtitlesEnabledChange,
@@ -207,13 +217,21 @@ export function EditorSubtitlePanel({
onValueChange={onSelectedSubtitleTrackKeyChange}
>
-
+ {
+ // The confirmation owns focus while it is open.
+ if (subtitleTrackChangePending) {
+ event.preventDefault();
+ }
+ }}
+ >
Subtitle Tracks
No subtitles
diff --git a/apps/frontend/src/components/editor/EditorTimeline.tsx b/apps/frontend/src/components/editor/EditorTimeline.tsx
index 7d4ed3f2..58ded085 100644
--- a/apps/frontend/src/components/editor/EditorTimeline.tsx
+++ b/apps/frontend/src/components/editor/EditorTimeline.tsx
@@ -1,3 +1,4 @@
+import { ControlTooltip } from "@/components/ui/tooltip";
import {
CanvasRenderer,
Timeline,
@@ -43,35 +44,41 @@ function TrackHeaderColumn({ muted, onMutedChange }: EditorTimelineProperties) {
{(header: UseTimelineTrackHeaderResult) => (
{track.id === EDITOR_MEDIA_TRACK_ID ? (
-
+
+
) : (
-
+
+
)}
{header.label}
diff --git a/apps/frontend/src/components/editor/EditorViewportScrollbar.tsx b/apps/frontend/src/components/editor/EditorViewportScrollbar.tsx
index 35e5ab2b..43d733f0 100644
--- a/apps/frontend/src/components/editor/EditorViewportScrollbar.tsx
+++ b/apps/frontend/src/components/editor/EditorViewportScrollbar.tsx
@@ -1,3 +1,4 @@
+import { ControlTooltip } from "@/components/ui/tooltip";
import {
RangeScrollbar,
Timeline,
@@ -37,92 +38,95 @@ function ZoomHandle({
};
return (
- ) => {
- const delta = {
- ArrowLeft: -10,
- ArrowRight: 10,
- PageUp: -120,
- PageDown: 120,
- }[event.key];
- if (delta === undefined) {
- return;
+
+ ) => {
+ const delta = {
+ ArrowLeft: -10,
+ ArrowRight: 10,
+ PageUp: -120,
+ PageDown: 120,
+ }[event.key];
+ if (delta === undefined) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ control.onValueChange(
+ viewportRangeAfterZoomDrag({
+ range: {
+ start: control.viewStartSeconds,
+ end: control.viewEndSeconds,
+ },
+ side,
+ deltaPixels: delta,
+ minSpan: control.range.minSpan,
+ duration: control.totalDurationSeconds,
+ }),
+ { reason: "handle-keyboard", side },
+ );
+ }}
+ onPointerDown={(event: PointerEvent) => {
+ // Preserve the library's accessible handle while replacing its
+ // full-duration linear pointer sensitivity with proportional zoom.
+ event.preventDefault();
+ event.stopPropagation();
+ if (
+ (event.pointerType !== "touch" && event.button !== 0) ||
+ drag.current
+ ) {
+ return;
+ }
+ event.currentTarget.focus();
+ drag.current = {
+ pointerId: event.pointerId,
+ clientX: event.clientX,
range: {
start: control.viewStartSeconds,
end: control.viewEndSeconds,
},
- side,
- deltaPixels: delta,
- minSpan: control.range.minSpan,
- duration: control.totalDurationSeconds,
- }),
- { reason: "handle-keyboard", side },
- );
- }}
- onPointerDown={(event: PointerEvent) => {
- // Preserve the library's accessible handle while replacing its
- // full-duration linear pointer sensitivity with proportional zoom.
- event.preventDefault();
- event.stopPropagation();
- if (
- (event.pointerType !== "touch" && event.button !== 0) ||
- drag.current
- ) {
- return;
- }
- event.currentTarget.focus();
- drag.current = {
- pointerId: event.pointerId,
- clientX: event.clientX,
- range: {
- start: control.viewStartSeconds,
- end: control.viewEndSeconds,
- },
- };
- event.currentTarget.setPointerCapture(event.pointerId);
- }}
- onPointerMove={(event: PointerEvent) => {
- const active = drag.current;
- if (!active || active.pointerId !== event.pointerId) {
- return;
- }
- control.onValueChange(
- viewportRangeAfterZoomDrag({
- range: active.range,
- side,
- deltaPixels: event.clientX - active.clientX,
- minSpan: control.range.minSpan,
- duration: control.totalDurationSeconds,
- }),
- { reason: "handle-drag", side },
- );
- }}
- onPointerUp={finishDrag}
- onPointerCancel={finishDrag}
- onLostPointerCapture={finishDrag}
- />
+ };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ }}
+ onPointerMove={(event: PointerEvent) => {
+ const active = drag.current;
+ if (!active || active.pointerId !== event.pointerId) {
+ return;
+ }
+ control.onValueChange(
+ viewportRangeAfterZoomDrag({
+ range: active.range,
+ side,
+ deltaPixels: event.clientX - active.clientX,
+ minSpan: control.range.minSpan,
+ duration: control.totalDurationSeconds,
+ }),
+ { reason: "handle-drag", side },
+ );
+ }}
+ onPointerUp={finishDrag}
+ onPointerCancel={finishDrag}
+ onLostPointerCapture={finishDrag}
+ />
+
);
}
diff --git a/apps/frontend/src/components/editor/useEditorSubtitles.ts b/apps/frontend/src/components/editor/useEditorSubtitles.ts
index 12c7b43e..b9968c2d 100644
--- a/apps/frontend/src/components/editor/useEditorSubtitles.ts
+++ b/apps/frontend/src/components/editor/useEditorSubtitles.ts
@@ -90,6 +90,9 @@ export function useEditorSubtitles({
const [selectedSubtitleTrackKey, setSelectedSubtitleTrackKey] = useState(
initialSubtitleSelection.key,
);
+ const [pendingSubtitleTrackKey, setPendingSubtitleTrackKey] = useState<
+ string | null
+ >(null);
const [importedSubtitleTrackKey, setImportedSubtitleTrackKey] = useState<
string | null
>(initialDraft?.subtitles.importedTrackKey ?? null);
@@ -241,20 +244,8 @@ export function useEditorSubtitles({
setSubtitleEnabled(subtitleTrackSupportsBurnIn(preferredSubtitleTrack));
}, [session.selectedSubtitleTrack, subtitleTracks]);
- const handleSelectedSubtitleTrackChange = useCallback(
+ const applySubtitleTrackChange = useCallback(
(value: string) => {
- if (
- value !== "none" &&
- value !== importedSubtitleTrackKey &&
- subtitleCues.length > 0 &&
- globalThis.window !== undefined &&
- !globalThis.confirm(
- "Changing subtitle tracks will replace your customized subtitle cues. Continue?",
- )
- ) {
- return;
- }
-
subtitleTrackSelectionChangedByUserReference.current = true;
setSelectedSubtitleTrackKey(value);
clearSubtitleError();
@@ -272,15 +263,33 @@ export function useEditorSubtitles({
Boolean(nextTrack && subtitleTrackSupportsBurnIn(nextTrack)),
);
},
- [
- clearSubtitleError,
- importedSubtitleTrackKey,
- resetSubtitleCues,
- subtitleCues.length,
- subtitleTracks,
- ],
+ [clearSubtitleError, resetSubtitleCues, subtitleTracks],
);
+ const handleSelectedSubtitleTrackChange = useCallback(
+ (value: string) => {
+ if (
+ value !== "none" &&
+ value !== importedSubtitleTrackKey &&
+ subtitleCues.length > 0
+ ) {
+ setPendingSubtitleTrackKey(value);
+ return;
+ }
+
+ applySubtitleTrackChange(value);
+ },
+ [applySubtitleTrackChange, importedSubtitleTrackKey, subtitleCues.length],
+ );
+
+ function confirmSubtitleTrackChange() {
+ if (pendingSubtitleTrackKey === null) {
+ return;
+ }
+ applySubtitleTrackChange(pendingSubtitleTrackKey);
+ setPendingSubtitleTrackKey(null);
+ }
+
const handleSelectedSubtitleTextCommit = useCallback(
(text: string) => {
if (!selectedSubtitleClip) {
@@ -385,6 +394,9 @@ export function useEditorSubtitles({
clippedSubtitleCues,
subtitleExportSummary,
handleSelectedSubtitleTrackChange,
+ subtitleTrackChangePending: pendingSubtitleTrackKey !== null,
+ confirmSubtitleTrackChange,
+ cancelSubtitleTrackChange: () => setPendingSubtitleTrackKey(null),
selectedSubtitleCue,
handleSelectedSubtitleTextCommit,
handleSelectedSubtitleStartCommit,
diff --git a/apps/frontend/src/components/frontendWorkflow.test.ts b/apps/frontend/src/components/frontendWorkflow.test.ts
index 8aec3a2f..c3bba962 100644
--- a/apps/frontend/src/components/frontendWorkflow.test.ts
+++ b/apps/frontend/src/components/frontendWorkflow.test.ts
@@ -412,30 +412,36 @@ void test("renders dashboard version badge as a release link when an update is a
void test("renders dashboard dev version badge with disabled update checks", () => {
const markup = renderToStaticMarkup(
- createElement(DashboardVersionBadge, {
- versionLabel: "dev",
- latestRelease: null,
- releaseChecksDisabledReason:
- "Local development build; release update checks are disabled",
- }),
+ createElement(
+ TooltipProvider,
+ null,
+ createElement(DashboardVersionBadge, {
+ versionLabel: "dev",
+ latestRelease: null,
+ releaseChecksDisabledReason:
+ "Local development build; release update checks are disabled",
+ }),
+ ),
);
assert.match(markup, /data-dashboard-version-badge/);
assert.match(markup, /data-dashboard-release-check-disabled="true"/);
assert.match(markup, />dev);
- assert.match(
- markup,
- /Local development build; release update checks are disabled/,
- );
+ assert.match(markup, /tabindex="0"/);
+ assert.doesNotMatch(markup, / title=/);
});
void test("renders mobile PWA install nudge for native install state", () => {
const markup = renderToStaticMarkup(
- createElement(MobilePwaInstallNudgeCard, {
- mode: "native",
- onDismiss: () => {},
- onInstall: () => {},
- }),
+ createElement(
+ TooltipProvider,
+ null,
+ createElement(MobilePwaInstallNudgeCard, {
+ mode: "native",
+ onDismiss: () => {},
+ onInstall: () => {},
+ }),
+ ),
);
assert.match(markup, /Add Cliparr to your home screen/);
@@ -445,11 +451,15 @@ void test("renders mobile PWA install nudge for native install state", () => {
void test("hides mobile PWA install nudge by default", () => {
const markup = renderToStaticMarkup(
- createElement(MobilePwaInstallNudgeCard, {
- mode: "hidden",
- onDismiss: () => {},
- onInstall: () => {},
- }),
+ createElement(
+ TooltipProvider,
+ null,
+ createElement(MobilePwaInstallNudgeCard, {
+ mode: "hidden",
+ onDismiss: () => {},
+ onInstall: () => {},
+ }),
+ ),
);
assert.equal(markup, "");
@@ -457,13 +467,17 @@ void test("hides mobile PWA install nudge by default", () => {
void test("does not render dashboard PWA nudge in default server markup", () => {
const markup = renderToStaticMarkup(
- createElement(DashboardScreen, {
- activeViewTransitionSessionId: null,
- onSelectSession: () => {},
- onOpenLocalVideo: () => {},
- onOpenSources: () => {},
- onDisconnect: () => {},
- }),
+ createElement(
+ TooltipProvider,
+ null,
+ createElement(DashboardScreen, {
+ activeViewTransitionSessionId: null,
+ onSelectSession: () => {},
+ onOpenLocalVideo: () => {},
+ onOpenSources: () => {},
+ onDisconnect: () => {},
+ }),
+ ),
);
assert.doesNotMatch(markup, /Add Cliparr to your home screen/);
@@ -471,13 +485,17 @@ void test("does not render dashboard PWA nudge in default server markup", () =>
void test("reserves dashboard playback card space before sessions load", () => {
const markup = renderToStaticMarkup(
- createElement(DashboardScreen, {
- activeViewTransitionSessionId: null,
- onSelectSession: () => {},
- onOpenLocalVideo: () => {},
- onOpenSources: () => {},
- onDisconnect: () => {},
- }),
+ createElement(
+ TooltipProvider,
+ null,
+ createElement(DashboardScreen, {
+ activeViewTransitionSessionId: null,
+ onSelectSession: () => {},
+ onOpenLocalVideo: () => {},
+ onOpenSources: () => {},
+ onDisconnect: () => {},
+ }),
+ ),
);
assert.match(markup, /data-dashboard-loading-grid/);
@@ -492,13 +510,17 @@ void test("reserves dashboard playback card space before sessions load", () => {
void test("reserves dashboard version badge space before health loads", () => {
const markup = renderToStaticMarkup(
- createElement(DashboardScreen, {
- activeViewTransitionSessionId: null,
- onSelectSession: () => {},
- onOpenLocalVideo: () => {},
- onOpenSources: () => {},
- onDisconnect: () => {},
- }),
+ createElement(
+ TooltipProvider,
+ null,
+ createElement(DashboardScreen, {
+ activeViewTransitionSessionId: null,
+ onSelectSession: () => {},
+ onOpenLocalVideo: () => {},
+ onOpenSources: () => {},
+ onDisconnect: () => {},
+ }),
+ ),
);
assert.match(markup, /data-dashboard-version-badge/);
@@ -507,7 +529,13 @@ void test("reserves dashboard version badge space before health loads", () => {
void test("renders mobile PWA install nudge on the initial eligible browser pass", () => {
withMobilePwaBrowserEnvironment(() => {
- const markup = renderToStaticMarkup(createElement(MobilePwaInstallNudge));
+ const markup = renderToStaticMarkup(
+ createElement(
+ TooltipProvider,
+ null,
+ createElement(MobilePwaInstallNudge),
+ ),
+ );
assert.match(markup, /Add Cliparr to your home screen/);
assert.match(markup, /data-pwa-install-mode="ios"/);
diff --git a/apps/frontend/src/components/sources/SourcesDialog.tsx b/apps/frontend/src/components/sources/SourcesDialog.tsx
index a74a8c4c..554594ce 100644
--- a/apps/frontend/src/components/sources/SourcesDialog.tsx
+++ b/apps/frontend/src/components/sources/SourcesDialog.tsx
@@ -1,6 +1,7 @@
import { useRef } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { DialogWindow } from "@/components/ui/dialog";
+import { ConfirmationDialog } from "@/components/ui/confirmation-dialog";
import {
SourceCard,
SourcesConnectSection,
@@ -70,7 +71,10 @@ export default function SourcesDialog({
saveSourceEdits,
toggleSourceEnabled,
checkSource,
- deleteSource,
+ sourceToRemove,
+ requestSourceRemoval,
+ cancelSourceRemoval,
+ confirmSourceRemoval,
updateDraftName,
updateDraftBaseUrl,
} = useSourcesState({
@@ -195,7 +199,7 @@ export default function SourcesDialog({
onSave={() => saveSourceEdits(source)}
onToggleEnabled={() => toggleSourceEnabled(source)}
onRefresh={() => checkSource(source)}
- onRemove={() => deleteSource(source)}
+ onRemove={() => requestSourceRemoval(source)}
/>
))}
@@ -212,6 +216,14 @@ export default function SourcesDialog({
portalClassName="p-4 sm:p-6"
popupClassName="h-full max-w-6xl rounded-lg"
>
+ void confirmSourceRemoval()}
+ />
-
-
- {children}
-
-
- {message}
-
- );
-}
-
interface SourceCounts {
all: number;
enabled: number;
@@ -274,7 +247,7 @@ export function SourcesDialogHeader({
{showConnectPanel ? "Hide" : "Add Source"}
)}
-
+
Reload
-
-
+
+
Refresh All
-
+