feat: playback speed options

This commit is contained in:
Fredrik Burmester
2026-01-03 19:14:20 +01:00
parent 1d8d92175a
commit 3ff7c47b7f
9 changed files with 465 additions and 1 deletions

View File

@@ -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/missingdata
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>

View 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>
);
};

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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;

View File

@@ -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)"
}
}
}

View File

@@ -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,