diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index af4dee46..a8ad0c3b 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -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} /> )} diff --git a/components/PlaybackSpeedSelector.tsx b/components/PlaybackSpeedSelector.tsx new file mode 100644 index 00000000..70b69ba4 --- /dev/null +++ b/components/PlaybackSpeedSelector.tsx @@ -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 { + onChange: (value: number, scope: PlaybackSpeedScope) => void; + selected: number; + item?: BaseItemDto; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export const PlaybackSpeedSelector: React.FC = ({ + 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(() => { + 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(initialScope); + + // Update selectedScope when initialScope changes + useEffect(() => { + setSelectedScope(initialScope); + }, [initialScope]); + + const open = controlledOpen !== undefined ? controlledOpen : internalOpen; + const setOpen = onOpenChange || setInternalOpen; + + const scopeLabels = useMemo>(() => { + const labels: Record = { + [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; + }, [item?.SeriesId, t]); + + const availableScopes = useMemo(() => { + 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(() => { + 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( + () => ( + + + + ), + [], + ); + + if (isTv) return null; + + return ( + + + + ); +}; diff --git a/components/settings/PlaybackControlsSettings.tsx b/components/settings/PlaybackControlsSettings.tsx index 26c16da0..59cb7a51 100644 --- a/components/settings/PlaybackControlsSettings.tsx +++ b/components/settings/PlaybackControlsSettings.tsx @@ -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 = () => { /> + + + + {PLAYBACK_SPEEDS.find( + (s) => s.value === settings.defaultPlaybackSpeed, + )?.label ?? "1x"} + + + + } + title={t("home.settings.other.default_playback_speed")} + /> + + void; api?: Api | null; downloadedFiles?: DownloadedItem[]; + // Playback speed props + playbackSpeed?: number; + setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; } export const Controls: FC = ({ @@ -93,6 +97,8 @@ export const Controls: FC = ({ 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 = ({ setVideoScaleFactor={setVideoScaleFactor} isZoomedToFill={isZoomedToFill} onZoomToggle={onZoomToggle} + playbackSpeed={playbackSpeed} + setPlaybackSpeed={setPlaybackSpeed} /> void; + // Playback speed props + playbackSpeed?: number; + setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; } export const HeaderControls: FC = ({ @@ -60,6 +65,8 @@ export const HeaderControls: FC = ({ setVideoScaleFactor, isZoomedToFill = false, onZoomToggle, + playbackSpeed = 1.0, + setPlaybackSpeed, }) => { const { settings } = useSettings(); const router = useRouter(); @@ -181,6 +188,14 @@ export const HeaderControls: FC = ({ /> )} + {/* Playback Speed Control */} + {!Platform.isTV && setPlaybackSpeed && ( + + )} {/* VLC-specific controls: Aspect Ratio and Scale/Zoom */} {useVlcPlayer && ( { + 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) => 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, + }); + } +}; diff --git a/hooks/usePlaybackSpeed.ts b/hooks/usePlaybackSpeed.ts new file mode 100644 index 00000000..f946c905 --- /dev/null +++ b/hooks/usePlaybackSpeed.ts @@ -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; diff --git a/translations/en.json b/translations/en.json index 583cfe0b..06117b71 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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)" + } } } diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 314e5a26..022d81a8 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -174,6 +174,10 @@ export type Settings = { enableH265ForChromecast: boolean; maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount; autoPlayEpisodeCount: number; + // Playback speed settings + defaultPlaybackSpeed: number; + playbackSpeedPerMedia: Record; + playbackSpeedPerShow: Record; // 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,