mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: playback speed options
This commit is contained in:
@@ -24,9 +24,14 @@ import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||
import {
|
||||
PlaybackSpeedScope,
|
||||
updatePlaybackSpeedSettings,
|
||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import {
|
||||
@@ -63,7 +68,7 @@ export default function page() {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const { settings } = useSettings();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
// Determine which player to use:
|
||||
// - Android always uses VLC
|
||||
@@ -88,6 +93,7 @@ export default function page() {
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [tracksReady, setTracksReady] = useState(false);
|
||||
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
|
||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
@@ -153,6 +159,35 @@ export default function page() {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Get the playback speed for this item based on settings
|
||||
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
|
||||
item,
|
||||
settings,
|
||||
);
|
||||
|
||||
// Handler for changing playback speed
|
||||
const handleSetPlaybackSpeed = useCallback(
|
||||
async (speed: number, scope: PlaybackSpeedScope) => {
|
||||
// Update settings based on scope
|
||||
updatePlaybackSpeedSettings(
|
||||
speed,
|
||||
scope,
|
||||
item ?? undefined,
|
||||
settings,
|
||||
updateSettings,
|
||||
);
|
||||
|
||||
// Apply speed to the current player
|
||||
setCurrentPlaybackSpeed(speed);
|
||||
if (useVlcPlayer) {
|
||||
await (videoRef.current as VlcPlayerViewRef)?.setRate?.(speed);
|
||||
} else {
|
||||
await (videoRef.current as SfPlayerViewRef)?.setSpeed?.(speed);
|
||||
}
|
||||
},
|
||||
[item, settings, updateSettings, useVlcPlayer],
|
||||
);
|
||||
|
||||
/** Gets the initial playback position from the URL. */
|
||||
const getInitialPlaybackTicks = useCallback((): number => {
|
||||
if (playbackPositionFromUrl) {
|
||||
@@ -963,6 +998,28 @@ export default function page() {
|
||||
applySubtitleSettings();
|
||||
}, [isVideoLoaded, settings, useVlcPlayer]);
|
||||
|
||||
// Apply initial playback speed when video loads
|
||||
useEffect(() => {
|
||||
if (!isVideoLoaded || !videoRef.current) return;
|
||||
|
||||
const applyInitialPlaybackSpeed = async () => {
|
||||
if (initialPlaybackSpeed !== 1.0) {
|
||||
setCurrentPlaybackSpeed(initialPlaybackSpeed);
|
||||
if (useVlcPlayer) {
|
||||
await (videoRef.current as VlcPlayerViewRef)?.setRate?.(
|
||||
initialPlaybackSpeed,
|
||||
);
|
||||
} else {
|
||||
await (videoRef.current as SfPlayerViewRef)?.setSpeed?.(
|
||||
initialPlaybackSpeed,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
applyInitialPlaybackSpeed();
|
||||
}, [isVideoLoaded, initialPlaybackSpeed, useVlcPlayer]);
|
||||
|
||||
// Show error UI first, before checking loading/missing‐data
|
||||
if (itemStatus.isError || streamStatus.isError) {
|
||||
return (
|
||||
@@ -1101,6 +1158,8 @@ export default function page() {
|
||||
onZoomToggle={handleZoomToggle}
|
||||
api={api}
|
||||
downloadedFiles={downloadedFiles}
|
||||
playbackSpeed={currentPlaybackSpeed}
|
||||
setPlaybackSpeed={handleSetPlaybackSpeed}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
180
components/PlaybackSpeedSelector.tsx
Normal file
180
components/PlaybackSpeedSelector.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
import { PlaybackSpeedScope } from "./video-player/controls/utils/playback-speed-settings";
|
||||
|
||||
export const PLAYBACK_SPEEDS = [
|
||||
{ label: "0.25x", value: 0.25 },
|
||||
{ label: "0.5x", value: 0.5 },
|
||||
{ label: "0.75x", value: 0.75 },
|
||||
{ label: "1x", value: 1.0 },
|
||||
{ label: "1.25x", value: 1.25 },
|
||||
{ label: "1.5x", value: 1.5 },
|
||||
{ label: "1.75x", value: 1.75 },
|
||||
{ label: "2x", value: 2.0 },
|
||||
{ label: "2.25x", value: 2.25 },
|
||||
{ label: "2.5x", value: 2.5 },
|
||||
{ label: "2.75x", value: 2.75 },
|
||||
{ label: "3x", value: 3.0 },
|
||||
];
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: number, scope: PlaybackSpeedScope) => void;
|
||||
selected: number;
|
||||
item?: BaseItemDto;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const PlaybackSpeedSelector: React.FC<Props> = ({
|
||||
onChange,
|
||||
selected,
|
||||
item,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
// Determine initial scope based on existing settings
|
||||
const initialScope = useMemo<PlaybackSpeedScope>(() => {
|
||||
if (!item || !settings) return PlaybackSpeedScope.All;
|
||||
|
||||
const itemId = item?.Id;
|
||||
if (!itemId) return PlaybackSpeedScope.All;
|
||||
|
||||
// Check for media-specific speed preference
|
||||
if (settings?.playbackSpeedPerMedia?.[itemId] !== undefined) {
|
||||
return PlaybackSpeedScope.Media;
|
||||
}
|
||||
|
||||
// Check for show-specific speed preference (only for episodes)
|
||||
const seriesId = item?.SeriesId;
|
||||
const perShowSettings = settings?.playbackSpeedPerShow;
|
||||
if (
|
||||
seriesId &&
|
||||
perShowSettings &&
|
||||
perShowSettings[seriesId] !== undefined
|
||||
) {
|
||||
return PlaybackSpeedScope.Show;
|
||||
}
|
||||
|
||||
// If no custom setting exists, check default playback speed
|
||||
// Show "All" if speed is not 1x, otherwise show "Media"
|
||||
return (settings?.defaultPlaybackSpeed ?? 1.0) !== 1.0
|
||||
? PlaybackSpeedScope.All
|
||||
: PlaybackSpeedScope.Media;
|
||||
}, [item?.Id, item?.SeriesId, settings]);
|
||||
|
||||
const [selectedScope, setSelectedScope] =
|
||||
useState<PlaybackSpeedScope>(initialScope);
|
||||
|
||||
// Update selectedScope when initialScope changes
|
||||
useEffect(() => {
|
||||
setSelectedScope(initialScope);
|
||||
}, [initialScope]);
|
||||
|
||||
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
const setOpen = onOpenChange || setInternalOpen;
|
||||
|
||||
const scopeLabels = useMemo<Record<PlaybackSpeedScope, string>>(() => {
|
||||
const labels: Record<string, string> = {
|
||||
[PlaybackSpeedScope.Media]: t("playback_speed.scope.media"),
|
||||
};
|
||||
|
||||
if (item?.SeriesId) {
|
||||
labels[PlaybackSpeedScope.Show] = t("playback_speed.scope.show");
|
||||
}
|
||||
|
||||
labels[PlaybackSpeedScope.All] = t("playback_speed.scope.all");
|
||||
|
||||
return labels as Record<PlaybackSpeedScope, string>;
|
||||
}, [item?.SeriesId, t]);
|
||||
|
||||
const availableScopes = useMemo<PlaybackSpeedScope[]>(() => {
|
||||
const scopes = [PlaybackSpeedScope.Media];
|
||||
if (item?.SeriesId) {
|
||||
scopes.push(PlaybackSpeedScope.Show);
|
||||
}
|
||||
scopes.push(PlaybackSpeedScope.All);
|
||||
return scopes;
|
||||
}, [item?.SeriesId]);
|
||||
|
||||
const handleSpeedSelect = useCallback(
|
||||
(speed: number) => {
|
||||
onChange(speed, selectedScope);
|
||||
setOpen(false);
|
||||
},
|
||||
[onChange, selectedScope, setOpen],
|
||||
);
|
||||
|
||||
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||
const groups: OptionGroup[] = [];
|
||||
|
||||
// Scope selection group
|
||||
groups.push({
|
||||
title: t("playback_speed.apply_to"),
|
||||
options: availableScopes.map((scope) => ({
|
||||
type: "radio" as const,
|
||||
label: scopeLabels[scope],
|
||||
value: scope,
|
||||
selected: selectedScope === scope,
|
||||
onPress: () => setSelectedScope(scope),
|
||||
})),
|
||||
});
|
||||
|
||||
// Speed selection group
|
||||
groups.push({
|
||||
title: t("playback_speed.speed"),
|
||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||
type: "radio" as const,
|
||||
label: speed.label,
|
||||
value: speed.value,
|
||||
selected: selected === speed.value,
|
||||
onPress: () => handleSpeedSelect(speed.value),
|
||||
})),
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [
|
||||
t,
|
||||
availableScopes,
|
||||
scopeLabels,
|
||||
selectedScope,
|
||||
selected,
|
||||
handleSpeedSelect,
|
||||
]);
|
||||
|
||||
const trigger = useMemo(
|
||||
() => (
|
||||
<View className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'>
|
||||
<Ionicons name='speedometer' size={24} color='white' />
|
||||
</View>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||
<PlatformDropdown
|
||||
title={t("playback_speed.title")}
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Switch, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
@@ -93,6 +94,21 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
const playbackSpeedOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||
type: "radio" as const,
|
||||
label: speed.label,
|
||||
value: speed.value,
|
||||
selected: speed.value === settings?.defaultPlaybackSpeed,
|
||||
onPress: () => updateSettings({ defaultPlaybackSpeed: speed.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.defaultPlaybackSpeed, updateSettings],
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
@@ -159,6 +175,30 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.default_playback_speed")}
|
||||
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={playbackSpeedOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between pl-3 py-1.5'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{PLAYBACK_SPEEDS.find(
|
||||
(s) => s.value === settings.defaultPlaybackSpeed,
|
||||
)?.label ?? "1x"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.default_playback_speed")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.disable_haptic_feedback")}
|
||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
||||
import { useVideoSlider } from "./hooks/useVideoSlider";
|
||||
import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
import { type ScaleFactor } from "./VlcZoomControl";
|
||||
|
||||
@@ -66,6 +67,9 @@ interface Props {
|
||||
onZoomToggle?: () => void;
|
||||
api?: Api | null;
|
||||
downloadedFiles?: DownloadedItem[];
|
||||
// Playback speed props
|
||||
playbackSpeed?: number;
|
||||
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
|
||||
}
|
||||
|
||||
export const Controls: FC<Props> = ({
|
||||
@@ -93,6 +97,8 @@ export const Controls: FC<Props> = ({
|
||||
offline = false,
|
||||
api = null,
|
||||
downloadedFiles = undefined,
|
||||
playbackSpeed = 1.0,
|
||||
setPlaybackSpeed,
|
||||
}) => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
@@ -484,6 +490,8 @@ export const Controls: FC<Props> = ({
|
||||
setVideoScaleFactor={setVideoScaleFactor}
|
||||
isZoomedToFill={isZoomedToFill}
|
||||
onZoomToggle={onZoomToggle}
|
||||
playbackSpeed={playbackSpeed}
|
||||
setPlaybackSpeed={setPlaybackSpeed}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
|
||||
@@ -7,12 +7,14 @@ import { useRouter } from "expo-router";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { PlaybackSpeedSelector } from "@/components/PlaybackSpeedSelector";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||
import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings";
|
||||
import { ICON_SIZES } from "./constants";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import {
|
||||
type AspectRatio,
|
||||
AspectRatioSelector,
|
||||
@@ -40,6 +42,9 @@ interface HeaderControlsProps {
|
||||
// KSPlayer-specific props
|
||||
isZoomedToFill?: boolean;
|
||||
onZoomToggle?: () => void;
|
||||
// Playback speed props
|
||||
playbackSpeed?: number;
|
||||
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
|
||||
}
|
||||
|
||||
export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
@@ -60,6 +65,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
setVideoScaleFactor,
|
||||
isZoomedToFill = false,
|
||||
onZoomToggle,
|
||||
playbackSpeed = 1.0,
|
||||
setPlaybackSpeed,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
@@ -181,6 +188,14 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{/* Playback Speed Control */}
|
||||
{!Platform.isTV && setPlaybackSpeed && (
|
||||
<PlaybackSpeedSelector
|
||||
selected={playbackSpeed}
|
||||
onChange={setPlaybackSpeed}
|
||||
item={item}
|
||||
/>
|
||||
)}
|
||||
{/* VLC-specific controls: Aspect Ratio and Scale/Zoom */}
|
||||
{useVlcPlayer && (
|
||||
<AspectRatioSelector
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
|
||||
export enum PlaybackSpeedScope {
|
||||
Media = "media",
|
||||
Show = "show",
|
||||
All = "all",
|
||||
}
|
||||
|
||||
interface ClearConflictingSettingsResult {
|
||||
readonly updatedPerMedia: Settings["playbackSpeedPerMedia"];
|
||||
readonly updatedPerShow: Settings["playbackSpeedPerShow"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears conflicting playback speed settings based on the selected scope.
|
||||
*
|
||||
* When setting a playback speed at a certain scope, this function removes
|
||||
* any more specific settings that would override the new setting:
|
||||
* - "all" scope: clears both media-specific and show-specific settings
|
||||
* - "media" scope: clears show-specific settings
|
||||
* - "show" scope: clears media-specific settings
|
||||
*/
|
||||
export const clearConflictingSettings = (
|
||||
scope: PlaybackSpeedScope,
|
||||
item: BaseItemDto | undefined,
|
||||
perMedia: Settings["playbackSpeedPerMedia"],
|
||||
perShow: Settings["playbackSpeedPerShow"],
|
||||
): ClearConflictingSettingsResult => {
|
||||
const updatedPerMedia = { ...perMedia };
|
||||
const updatedPerShow = { ...perShow };
|
||||
|
||||
if (scope === "all") {
|
||||
// Clear both media-specific and show-specific settings
|
||||
if (item?.Id && updatedPerMedia[item.Id] !== undefined) {
|
||||
delete updatedPerMedia[item.Id];
|
||||
}
|
||||
if (item?.SeriesId && updatedPerShow[item.SeriesId] !== undefined) {
|
||||
delete updatedPerShow[item.SeriesId];
|
||||
}
|
||||
} else if (scope === "media") {
|
||||
// Clear show-specific setting only
|
||||
if (item?.SeriesId && updatedPerShow[item.SeriesId] !== undefined) {
|
||||
delete updatedPerShow[item.SeriesId];
|
||||
}
|
||||
} else if (scope === "show") {
|
||||
// Clear media-specific setting only
|
||||
if (item?.Id && updatedPerMedia[item.Id] !== undefined) {
|
||||
delete updatedPerMedia[item.Id];
|
||||
}
|
||||
}
|
||||
|
||||
return { updatedPerMedia, updatedPerShow };
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates playback speed settings based on the selected scope and speed.
|
||||
*
|
||||
* This function handles both clearing conflicting settings and updating
|
||||
* the appropriate setting based on the scope:
|
||||
* - "all": updates the default playback speed
|
||||
* - "media": sets a speed for the specific media item
|
||||
* - "show": sets a speed for the entire show
|
||||
*/
|
||||
export const updatePlaybackSpeedSettings = (
|
||||
speed: number,
|
||||
scope: PlaybackSpeedScope,
|
||||
item: BaseItemDto | undefined,
|
||||
settings: Settings,
|
||||
updateSettings: (updates: Partial<Settings>) => void,
|
||||
): void => {
|
||||
const { updatedPerMedia, updatedPerShow } = clearConflictingSettings(
|
||||
scope,
|
||||
item,
|
||||
settings.playbackSpeedPerMedia,
|
||||
settings.playbackSpeedPerShow,
|
||||
);
|
||||
|
||||
if (scope === "all") {
|
||||
updateSettings({
|
||||
defaultPlaybackSpeed: speed,
|
||||
playbackSpeedPerMedia: updatedPerMedia,
|
||||
playbackSpeedPerShow: updatedPerShow,
|
||||
});
|
||||
} else if (scope === "media" && item?.Id) {
|
||||
updatedPerMedia[item.Id] = speed;
|
||||
updateSettings({
|
||||
playbackSpeedPerMedia: updatedPerMedia,
|
||||
playbackSpeedPerShow: updatedPerShow,
|
||||
});
|
||||
} else if (scope === "show" && item?.SeriesId) {
|
||||
updatedPerShow[item.SeriesId] = speed;
|
||||
updateSettings({
|
||||
playbackSpeedPerShow: updatedPerShow,
|
||||
playbackSpeedPerMedia: updatedPerMedia,
|
||||
});
|
||||
}
|
||||
};
|
||||
45
hooks/usePlaybackSpeed.ts
Normal file
45
hooks/usePlaybackSpeed.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
|
||||
/**
|
||||
* Determines the appropriate playback speed for a media item based on a three-tier priority system:
|
||||
* 1. Media-specific speed (highest priority)
|
||||
* 2. Series-specific speed (medium priority)
|
||||
* 3. Default speed (lowest priority)
|
||||
*/
|
||||
const usePlaybackSpeed = (
|
||||
item: BaseItemDto | null,
|
||||
settings: Settings | null,
|
||||
): { readonly playbackSpeed: number } => {
|
||||
const playbackSpeed = useMemo(() => {
|
||||
if (!settings || !item) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Start with the lowest priority: default playback speed
|
||||
let selectedPlaybackSpeed = settings.defaultPlaybackSpeed;
|
||||
|
||||
// Second priority: use what is set for Series if it is a Series
|
||||
if (item.SeriesId && settings.playbackSpeedPerShow[item.SeriesId]) {
|
||||
selectedPlaybackSpeed = settings.playbackSpeedPerShow[item.SeriesId];
|
||||
}
|
||||
|
||||
// Highest priority: use what is set for Media if it is set
|
||||
if (item.Id && settings.playbackSpeedPerMedia[item.Id] !== undefined) {
|
||||
selectedPlaybackSpeed = settings.playbackSpeedPerMedia[item.Id];
|
||||
}
|
||||
|
||||
return selectedPlaybackSpeed;
|
||||
}, [
|
||||
item?.Id,
|
||||
item?.SeriesId,
|
||||
settings?.defaultPlaybackSpeed,
|
||||
settings?.playbackSpeedPerMedia,
|
||||
settings?.playbackSpeedPerShow,
|
||||
]);
|
||||
|
||||
return { playbackSpeed };
|
||||
};
|
||||
|
||||
export default usePlaybackSpeed;
|
||||
@@ -214,6 +214,7 @@
|
||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||
"disable_haptic_feedback": "Disable Haptic Feedback",
|
||||
"default_quality": "Default Quality",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
@@ -663,5 +664,15 @@
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,10 @@ export type Settings = {
|
||||
enableH265ForChromecast: boolean;
|
||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||
autoPlayEpisodeCount: number;
|
||||
// Playback speed settings
|
||||
defaultPlaybackSpeed: number;
|
||||
playbackSpeedPerMedia: Record<string, number>;
|
||||
playbackSpeedPerShow: Record<string, number>;
|
||||
// MPV subtitle settings
|
||||
mpvSubtitleScale?: number;
|
||||
mpvSubtitleMarginY?: number;
|
||||
@@ -250,6 +254,10 @@ export const defaultValues: Settings = {
|
||||
enableH265ForChromecast: false,
|
||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||
autoPlayEpisodeCount: 0,
|
||||
// Playback speed defaults
|
||||
defaultPlaybackSpeed: 1.0,
|
||||
playbackSpeedPerMedia: {},
|
||||
playbackSpeedPerShow: {},
|
||||
// MPV subtitle defaults
|
||||
mpvSubtitleScale: undefined,
|
||||
mpvSubtitleMarginY: undefined,
|
||||
|
||||
Reference in New Issue
Block a user