p-stream/src/components/player/atoms/settings/PlaybackSettingsView.tsx
Pas 3de8a35df4 Prevent settings from loading empty and rewriting to backend
- No saves happen until backend settings are loaded and applied
- Automatic syncers wait for settings to be loaded before syncing
2025-10-25 12:07:15 -06:00

316 lines
10 KiB
TypeScript

import classNames from "classnames";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { updateSettings } from "@/backend/accounts/settings";
import { Toggle } from "@/components/buttons/Toggle";
import { Icon, Icons } from "@/components/Icon";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { useAuthStore } from "@/stores/auth";
import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences";
import { useWatchPartyStore } from "@/stores/watchParty";
import { isAutoplayAllowed } from "@/utils/autoplay";
function ButtonList(props: {
options: number[];
selected: number;
onClick: (v: any) => void;
disabled?: boolean;
}) {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [customValue, setCustomValue] = useState<string>("");
const [isCustomSpeed, setIsCustomSpeed] = useState(false);
// Check if current speed is a custom value (not in preset options)
useEffect(() => {
if (!props.options.includes(props.selected)) {
setIsCustomSpeed(true);
} else {
setIsCustomSpeed(false);
}
}, [props.selected, props.options]);
const handleButtonClick = useCallback(
(option: number, index: number) => {
if (editingIndex === index) {
// Already in edit mode, do nothing
return;
}
// If clicking the custom speed button, enter edit mode
if (isCustomSpeed && option === props.selected) {
setEditingIndex(0);
setCustomValue(option.toString());
return;
}
props.onClick(option);
setIsCustomSpeed(false);
},
[editingIndex, props, isCustomSpeed],
);
const handleDoubleClick = useCallback(
(option: number, index: number) => {
if (props.disabled) return;
setEditingIndex(index);
setCustomValue(option.toString());
},
[props.disabled],
);
const handleCustomValueChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setCustomValue(e.target.value);
},
[],
);
const handleCustomValueKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
const value = parseFloat(customValue);
if (!Number.isNaN(value) && value > 0 && value <= 5) {
props.onClick(value);
setEditingIndex(null);
setIsCustomSpeed(true);
}
} else if (e.key === "Escape") {
setEditingIndex(null);
}
},
[customValue, props],
);
const handleInputBlur = useCallback(() => {
setEditingIndex(null);
}, []);
const handleResetCustomSpeed = useCallback(() => {
setIsCustomSpeed(false);
props.onClick(1); // Reset to default speed (1x)
}, [props]);
return (
<div className="flex items-center bg-video-context-light/10 p-1 rounded-lg">
{isCustomSpeed ? (
// Show only the custom speed button when a custom speed is set
<button
type="button"
disabled={props.disabled}
className={classNames(
"w-full px-2 py-1 rounded-md tabbable relative",
"bg-video-context-light/20 text-white",
props.disabled ? "opacity-50 cursor-not-allowed" : null,
)}
onClick={() => handleButtonClick(props.selected, 0)}
onDoubleClick={() => handleDoubleClick(props.selected, 0)}
key="custom"
>
{editingIndex === 0 ? (
<input
type="text"
value={customValue}
onChange={handleCustomValueChange}
onKeyDown={handleCustomValueKeyDown}
onBlur={handleInputBlur}
className="w-full bg-transparent text-center focus:outline-none"
autoFocus
aria-label="Custom playback speed"
/>
) : (
<>
{`${props.selected}x`}
<button
type="button"
className="absolute right-1 top-1/2 transform -translate-y-1/2 text-xs text-video-context-light/70 hover:text-white"
onClick={handleResetCustomSpeed}
title="Reset to presets"
>
<Icon icon={Icons.X} className="text-sm" />
</button>
</>
)}
</button>
) : (
// Show all preset options when no custom speed is set
props.options.map((option, index) => {
const isEditing = editingIndex === index;
return (
<button
type="button"
disabled={props.disabled}
className={classNames(
"w-full px-2 py-1 rounded-md tabbable relative",
props.selected === option
? "bg-video-context-light/20 text-white"
: null,
props.disabled ? "opacity-50 cursor-not-allowed" : null,
)}
onClick={() => handleButtonClick(option, index)}
onDoubleClick={() => handleDoubleClick(option, index)}
key={option}
>
{isEditing ? (
<input
type="text"
value={customValue}
onChange={handleCustomValueChange}
onKeyDown={handleCustomValueKeyDown}
onBlur={handleInputBlur}
className="w-full bg-transparent text-center focus:outline-none"
autoFocus
aria-label="Custom playback speed"
/>
) : (
`${option}x`
)}
</button>
);
})
)}
</div>
);
}
export function PlaybackSettingsView({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate);
const display = usePlayerStore((s) => s.display);
const enableThumbnails = usePreferencesStore((s) => s.enableThumbnails);
const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails);
const enableAutoplay = usePreferencesStore((s) => s.enableAutoplay);
const setEnableAutoplay = usePreferencesStore((s) => s.setEnableAutoplay);
const enableLowPerformanceMode = usePreferencesStore(
(s) => s.enableLowPerformanceMode,
);
const isInWatchParty = useWatchPartyStore((s) => s.enabled);
const account = useAuthStore((s) => s.account);
const backendUrl = useBackendUrl();
const settingsLoaded = usePreferencesStore((s) => s._settingsLoaded);
const allowAutoplay = useMemo(() => isAutoplayAllowed(), []);
const canShowAutoplay =
!isInWatchParty && allowAutoplay && !enableLowPerformanceMode;
// Save settings to backend
const saveThumbnailSetting = useCallback(
async (value: boolean) => {
if (!account || !backendUrl || !settingsLoaded) return;
try {
await updateSettings(backendUrl, account, {
enableThumbnails: value,
});
} catch (error) {
console.error("Failed to save thumbnail setting:", error);
}
},
[account, backendUrl, settingsLoaded],
);
const saveAutoplaySetting = useCallback(
async (value: boolean) => {
if (!account || !backendUrl || !settingsLoaded) return;
try {
await updateSettings(backendUrl, account, {
enableAutoplay: value,
});
} catch (error) {
console.error("Failed to save autoplay setting:", error);
}
},
[account, backendUrl, settingsLoaded],
);
const setPlaybackRate = useCallback(
(v: number) => {
if (isInWatchParty) return; // Don't allow changes in watch party
display?.setPlaybackRate(v);
},
[display, isInWatchParty],
);
// Handle thumbnail toggle with backend save
const handleThumbnailToggle = useCallback(() => {
const newValue = !enableThumbnails;
setEnableThumbnails(newValue);
saveThumbnailSetting(newValue);
}, [enableThumbnails, setEnableThumbnails, saveThumbnailSetting]);
// Handle autoplay toggle with backend save
const handleAutoplayToggle = useCallback(() => {
const newValue = !enableAutoplay;
setEnableAutoplay(newValue);
saveAutoplaySetting(newValue);
}, [enableAutoplay, setEnableAutoplay, saveAutoplaySetting]);
// Force 1x speed in watch party
useEffect(() => {
if (isInWatchParty && display && playbackRate !== 1) {
display.setPlaybackRate(1);
}
}, [isInWatchParty, display, playbackRate]);
const options = [0.25, 0.5, 1, 1.5, 2];
return (
<>
<Menu.BackLink onClick={() => router.navigate("/")}>
{t("player.menus.playback.title")}
</Menu.BackLink>
<Menu.Section>
<div className="space-y-4 mt-3">
<Menu.FieldTitle>
{t("player.menus.playback.speedLabel")}
{isInWatchParty && (
<span className="text-sm text-type-secondary ml-2">
{t("player.menus.playback.disabled")}
</span>
)}
</Menu.FieldTitle>
<ButtonList
options={options}
selected={isInWatchParty ? 1 : playbackRate}
onClick={setPlaybackRate}
disabled={isInWatchParty}
/>
</div>
</Menu.Section>
<Menu.Section>
<div className="space-y-4 mt-3">
{canShowAutoplay && (
<Menu.Link
rightSide={
<Toggle
enabled={enableAutoplay}
onClick={handleAutoplayToggle}
/>
}
>
{t("settings.preferences.autoplayLabel")}
</Menu.Link>
)}
{!enableLowPerformanceMode && (
<Menu.Link
rightSide={
<Toggle
enabled={enableThumbnails}
onClick={handleThumbnailToggle}
/>
}
>
{t("settings.preferences.thumbnailLabel")}
</Menu.Link>
)}
</div>
</Menu.Section>
</>
);
}