import { useEffect, useId, useState, type ReactNode } from "react";
import type { LibraryPlaybackPreference, Profile, UserLibrary } from "@/api/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
import {
useDeleteLibraryPlaybackPreference,
useLibraryPlaybackPreferences,
useSetLibraryPlaybackPreference,
} from "@/hooks/queries/libraryPlaybackPreferences";
import {
DISABLED_LIBRARY_IDS_SETTING_KEY,
LIBRARY_ORDER_SETTING_KEY,
applyLibraryOrder,
parseDisabledLibraryIDs,
parseLibraryOrder,
serializeDisabledLibraryIDs,
serializeLibraryOrder,
useAvailableUserLibraries,
} from "@/hooks/queries/libraries";
import { useSetting, useSetSetting } from "@/hooks/queries/settings";
import {
buildInheritedLanguageLabel,
buildInheritedShowForcedSubtitlesLabel,
buildInheritedSubtitleLanguageLabel,
buildInheritedSubtitleModeLabel,
buildLibraryPlaybackRequest,
buildLibraryPlaybackSummaryFromState,
createLibraryPlaybackEditorState,
getProfileDefaultForcedSubtitlesHint,
getProfileDefaultLanguageHint,
getProfileDefaultSubtitleLanguageHint,
getProfileDefaultSubtitleModeHint,
hasLibraryPlaybackOverride,
INHERIT_VALUE,
LANGUAGE_OPTIONS,
type LibraryPlaybackEditorState,
NONE_VALUE,
ORIGINAL_LANGUAGE_LABEL,
ORIGINAL_LANGUAGE_VALUE,
SUBTITLE_MODE_OPTIONS,
} from "./libraryPlaybackPreferences";
import { toast } from "sonner";
import { ChevronDown, ChevronRight, Eye, EyeOff, GripVertical, RotateCcw } from "lucide-react";
import {
DndContext,
DragOverlay,
PointerSensor,
KeyboardSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragStartEvent, DragEndEvent } from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
function sortLibrariesByOrder(libraries: UserLibrary[], ids: number[]) {
const selected = new Set(ids);
return libraries.filter((library) => selected.has(library.id)).map((library) => library.id);
}
function PlaybackField({
label,
value,
disabled,
hint,
onChange,
children,
}: {
label: string;
value: string;
disabled?: boolean;
hint?: string;
onChange: (value: string) => void;
children: ReactNode;
}) {
const controlId = useId();
return (
{hint &&
{hint}
}
);
}
function SortableLibraryCard({ id, children }: { id: number; children: React.ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
);
}
function LibraryCard({
library,
enabled,
visibilityDisabled = false,
preference,
profile,
onToggleVisibility,
}: {
library: UserLibrary;
enabled: boolean;
visibilityDisabled?: boolean;
preference: LibraryPlaybackPreference | null;
profile: Profile;
onToggleVisibility: (checked: boolean) => void;
}) {
const controlId = useId();
const setPlaybackPreference = useSetLibraryPlaybackPreference();
const deletePlaybackPreference = useDeleteLibraryPlaybackPreference();
const [expanded, setExpanded] = useState(false);
const [editorState, setEditorState] = useState(() =>
createLibraryPlaybackEditorState(preference),
);
useEffect(() => {
// Keep the inline editor aligned with cached mutations and profile switches.
// eslint-disable-next-line react-hooks/set-state-in-effect
setEditorState(createLibraryPlaybackEditorState(preference));
}, [preference, profile.id]);
const playbackPending = setPlaybackPreference.isPending || deletePlaybackPreference.isPending;
const summaryText = buildLibraryPlaybackSummaryFromState(editorState);
const hasOverride = hasLibraryPlaybackOverride(editorState);
function savePlaybackState(
nextState: LibraryPlaybackEditorState,
rollbackState: LibraryPlaybackEditorState,
) {
setEditorState(nextState);
const onError = () => {
setEditorState(rollbackState);
toast.error("Failed to update playback defaults");
};
if (!hasLibraryPlaybackOverride(nextState)) {
deletePlaybackPreference.mutate(library.id, {
onError,
onSuccess: () => setExpanded(false),
});
return;
}
setPlaybackPreference.mutate(
{
libraryId: library.id,
body: buildLibraryPlaybackRequest(nextState),
},
{ onError },
);
}
function handlePlaybackChange(field: keyof LibraryPlaybackEditorState, value: string) {
const rollbackState = editorState;
const nextState = { ...editorState, [field]: value };
savePlaybackState(nextState, rollbackState);
}
function handleReset() {
savePlaybackState(createLibraryPlaybackEditorState(null), editorState);
}
return (
{library.name}
{library.type}
{hasOverride && (
Custom
)}
{summaryText}
{expanded && (
Override your profile's playback defaults for this library. Changes save
automatically.
handlePlaybackChange("audioLanguage", value)}
>
{buildInheritedLanguageLabel(profile.language)}
{ORIGINAL_LANGUAGE_LABEL}
{LANGUAGE_OPTIONS.map((language) => (
{language.label}
))}
handlePlaybackChange("subtitleLanguage", value)}
>
{buildInheritedSubtitleLanguageLabel(profile.subtitle_language)}
None
{LANGUAGE_OPTIONS.map((language) => (
{language.label}
))}
handlePlaybackChange("subtitleMode", value)}
>
{buildInheritedSubtitleModeLabel(profile.subtitle_mode)}
{SUBTITLE_MODE_OPTIONS.map((mode) => (
{mode.label}
))}
handlePlaybackChange("showForcedSubtitles", value)}
>
{buildInheritedShowForcedSubtitlesLabel(profile.show_forced_subtitles)}
On
Off
{hasOverride && (
)}
)}
);
}
export default function LibrarySettings() {
const { data: libraries, isLoading: librariesLoading } = useAvailableUserLibraries();
const { data: disabledSetting, isLoading: disabledSettingLoading } = useSetting(
DISABLED_LIBRARY_IDS_SETTING_KEY,
);
const { data: orderSetting, isLoading: orderSettingLoading } =
useSetting(LIBRARY_ORDER_SETTING_KEY);
const { profile: currentProfile, isLoading: profileLoading } = useCurrentProfile();
const { data: playbackPreferences, isLoading: playbackPrefsLoading } =
useLibraryPlaybackPreferences({
enabled: !!currentProfile,
});
const setSetting = useSetSetting();
const [disabledLibraryIDs, setDisabledLibraryIDs] = useState([]);
const [orderedLibraries, setOrderedLibraries] = useState([]);
const [activeId, setActiveId] = useState(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
const parsedIDs = parseDisabledLibraryIDs(disabledSetting);
// Keep the editable local order in sync with the latest saved setting.
// This supports optimistic updates and rollback without changing behavior.
// eslint-disable-next-line react-hooks/set-state-in-effect
setDisabledLibraryIDs(libraries ? sortLibrariesByOrder(libraries, parsedIDs) : parsedIDs);
}, [disabledSetting, libraries]);
useEffect(() => {
if (!libraries) return;
const order = parseLibraryOrder(orderSetting);
// eslint-disable-next-line react-hooks/set-state-in-effect
setOrderedLibraries(order.length > 0 ? applyLibraryOrder(libraries, order) : libraries);
}, [libraries, orderSetting]);
function saveDisabledLibraries(nextDisabledLibraryIDs: number[], rollbackIDs: number[]) {
setDisabledLibraryIDs(nextDisabledLibraryIDs);
setSetting.mutate(
{
key: DISABLED_LIBRARY_IDS_SETTING_KEY,
value: serializeDisabledLibraryIDs(nextDisabledLibraryIDs),
},
{
onError: () => {
setDisabledLibraryIDs(rollbackIDs);
toast.error("Failed to update library visibility");
},
},
);
}
function handleLibraryToggle(libraryId: number, enabled: boolean) {
if (!libraries) return;
const current = disabledLibraryIDs;
const next = enabled
? current.filter((id) => id !== libraryId)
: sortLibrariesByOrder(libraries, [...current, libraryId]);
saveDisabledLibraries(next, current);
}
function handleEnableAll() {
saveDisabledLibraries([], disabledLibraryIDs);
}
function handleDisableAll() {
if (!libraries) return;
saveDisabledLibraries(
libraries.map((library) => library.id),
disabledLibraryIDs,
);
}
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as number);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = orderedLibraries.findIndex((l) => l.id === active.id);
const newIndex = orderedLibraries.findIndex((l) => l.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const next = arrayMove(orderedLibraries, oldIndex, newIndex);
const prev = orderedLibraries;
setOrderedLibraries(next);
setSetting.mutate(
{
key: LIBRARY_ORDER_SETTING_KEY,
value: serializeLibraryOrder(next.map((l) => l.id)),
},
{
onError: () => {
setOrderedLibraries(prev);
toast.error("Failed to update library order");
},
},
);
}
function handleDragCancel() {
setActiveId(null);
}
const activeLibrary = activeId != null ? orderedLibraries.find((l) => l.id === activeId) : null;
if (
librariesLoading ||
disabledSettingLoading ||
orderSettingLoading ||
profileLoading ||
playbackPrefsLoading
) {
return Loading libraries...
;
}
if (!libraries || libraries.length === 0) {
return (
No libraries are available for this account right now.
);
}
if (!currentProfile) {
return Choose a profile to manage libraries.
;
}
const displayLibraries = orderedLibraries.length > 0 ? orderedLibraries : libraries;
const visibleCount = libraries.filter(
(library) => !disabledLibraryIDs.includes(library.id),
).length;
const preferencesByLibraryId = new Map(
(playbackPreferences ?? []).map((preference) => [preference.library_id, preference]),
);
return (
Libraries
Toggle which libraries appear in your navigation and customize playback defaults per
library.
{visibleCount} of {libraries.length}{" "}
libraries visible for this profile.
l.id)}
strategy={verticalListSortingStrategy}
>
{displayLibraries.map((library) => {
const enabled = !disabledLibraryIDs.includes(library.id);
return (
handleLibraryToggle(library.id, checked)}
/>
);
})}
{activeLibrary ? (
{activeLibrary.name}
{activeLibrary.type}
) : null}
);
}