feat: scale factor and aspect ratio (#942)

This commit is contained in:
Fredrik Burmester
2025-08-18 14:24:45 +02:00
committed by GitHub
parent 4fed25a3ab
commit 9410239c48
10 changed files with 361 additions and 43 deletions

View File

@@ -16,7 +16,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native"; import { Alert, Platform, View } from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -38,12 +38,9 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { generateDeviceProfile } from "@/utils/profiles/native"; import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
export default function page() { export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
@@ -53,11 +50,12 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => { const [aspectRatio, setAspectRatio] = useState<
// Load persisted state from storage "default" | "16:9" | "4:3" | "1:1" | "21:9"
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY); >("default");
return saved ?? false; const [scaleFactor, setScaleFactor] = useState<
}); 1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
>(1.0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true); const [isBuffering, setIsBuffering] = useState(true);
@@ -82,11 +80,6 @@ export default function page() {
lightHapticFeedback(); lightHapticFeedback();
}, []); }, []);
// Persist ignoreSafeAreas state whenever it changes
useEffect(() => {
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
}, [ignoreSafeAreas]);
const { const {
itemId, itemId,
audioIndex: audioIndexStr, audioIndex: audioIndexStr,
@@ -106,7 +99,7 @@ export default function page() {
playbackPosition?: string; playbackPosition?: string;
}>(); }>();
const [settings] = useSettings(); const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager(); const playbackManager = usePlaybackManager();
@@ -571,7 +564,14 @@ export default function page() {
); );
return ( return (
<View style={{ flex: 1, backgroundColor: "black" }}> <View
style={{
flex: 1,
backgroundColor: "blue",
height: "100%",
width: "100%",
}}
>
<View <View
style={{ style={{
display: "flex", display: "flex",
@@ -580,8 +580,6 @@ export default function page() {
position: "relative", position: "relative",
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}} }}
> >
<VlcPlayerView <VlcPlayerView
@@ -625,13 +623,11 @@ export default function page() {
isBuffering={isBuffering} isBuffering={isBuffering}
showControls={showControls} showControls={showControls}
setShowControls={setShowControls} setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef.current?.startPictureInPicture} startPictureInPicture={videoRef.current?.startPictureInPicture}
play={videoRef.current?.play} play={videoRef.current?.play || (() => {})}
pause={videoRef.current?.pause} pause={videoRef.current?.pause || (() => {})}
seek={videoRef.current?.seekTo} seek={videoRef.current?.seekTo || (() => {})}
enableTrickplay={true} enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks} getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks} getSubtitleTracks={videoRef.current?.getSubtitleTracks}
@@ -639,6 +635,12 @@ export default function page() {
setSubtitleTrack={videoRef.current?.setSubtitleTrack} setSubtitleTrack={videoRef.current?.setSubtitleTrack}
setSubtitleURL={videoRef.current?.setSubtitleURL} setSubtitleURL={videoRef.current?.setSubtitleURL}
setAudioTrack={videoRef.current?.setAudioTrack} setAudioTrack={videoRef.current?.setAudioTrack}
setVideoAspectRatio={videoRef.current?.setVideoAspectRatio}
setVideoScaleFactor={videoRef.current?.setVideoScaleFactor}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc isVlc
/> />
)} )}

View File

@@ -59,8 +59,13 @@ import { VideoProvider } from "./contexts/VideoContext";
import DropdownView from "./dropdown/DropdownView"; import DropdownView from "./dropdown/DropdownView";
import { EpisodeList } from "./EpisodeList"; import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
import SkipButton from "./SkipButton"; import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout"; import { useControlsTimeout } from "./useControlsTimeout";
import {
type AspectRatio,
AspectRatioSelector,
} from "./VideoScalingModeSelector";
import { VideoTouchOverlay } from "./VideoTouchOverlay"; import { VideoTouchOverlay } from "./VideoTouchOverlay";
interface Props { interface Props {
@@ -72,8 +77,7 @@ interface Props {
progress: SharedValue<number>; progress: SharedValue<number>;
isBuffering: boolean; isBuffering: boolean;
showControls: boolean; showControls: boolean;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: Dispatch<SetStateAction<boolean>>;
enableTrickplay?: boolean; enableTrickplay?: boolean;
togglePlay: () => void; togglePlay: () => void;
setShowControls: (shown: boolean) => void; setShowControls: (shown: boolean) => void;
@@ -89,6 +93,12 @@ interface Props {
setSubtitleURL?: (url: string, customName: string) => void; setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void; setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void; setAudioTrack?: (index: number) => void;
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
aspectRatio?: AspectRatio;
scaleFactor?: ScaleFactor;
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
isVlc?: boolean; isVlc?: boolean;
} }
@@ -108,8 +118,6 @@ export const Controls: FC<Props> = ({
cacheProgress, cacheProgress,
showControls, showControls,
setShowControls, setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
mediaSource, mediaSource,
isVideoLoaded, isVideoLoaded,
getAudioTracks, getAudioTracks,
@@ -117,6 +125,12 @@ export const Controls: FC<Props> = ({
setSubtitleURL, setSubtitleURL,
setSubtitleTrack, setSubtitleTrack,
setAudioTrack, setAudioTrack,
setVideoAspectRatio,
setVideoScaleFactor,
aspectRatio = "default",
scaleFactor = 1.0,
setAspectRatio,
setScaleFactor,
offline = false, offline = false,
isVlc = false, isVlc = false,
}) => { }) => {
@@ -631,10 +645,26 @@ export const Controls: FC<Props> = ({
} }
}, [settings, isPlaying, isVlc, play, seek]); }, [settings, isPlaying, isVlc, play, seek]);
const toggleIgnoreSafeAreas = useCallback(() => { const handleAspectRatioChange = useCallback(
setIgnoreSafeAreas((prev) => !prev); async (newRatio: AspectRatio) => {
lightHapticFeedback(); if (!setAspectRatio || !setVideoAspectRatio) return;
}, []);
setAspectRatio(newRatio);
const aspectRatioString = newRatio === "default" ? null : newRatio;
await setVideoAspectRatio(aspectRatioString);
},
[setAspectRatio, setVideoAspectRatio],
);
const handleScaleFactorChange = useCallback(
async (newScale: ScaleFactor) => {
if (!setScaleFactor || !setVideoScaleFactor) return;
setScaleFactor(newScale);
await setVideoScaleFactor(newScale);
},
[setScaleFactor, setVideoScaleFactor],
);
const switchOnEpisodeMode = useCallback(() => { const switchOnEpisodeMode = useCallback(() => {
setEpisodeView(true); setEpisodeView(true);
@@ -801,17 +831,17 @@ export const Controls: FC<Props> = ({
<Ionicons name='play-skip-forward' size={24} color='white' /> <Ionicons name='play-skip-forward' size={24} color='white' />
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* {mediaSource?.TranscodingUrl && ( */} {/* Video Controls */}
<TouchableOpacity <AspectRatioSelector
onPress={toggleIgnoreSafeAreas} currentRatio={aspectRatio}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' onRatioChange={handleAspectRatioChange}
> disabled={!setVideoAspectRatio}
<Ionicons />
name={ignoreSafeAreas ? "contract-outline" : "expand"} <ScaleFactorSelector
size={24} currentScale={scaleFactor}
color='white' onScaleChange={handleScaleFactorChange}
/> disabled={!setVideoScaleFactor}
</TouchableOpacity> />
<TouchableOpacity <TouchableOpacity
onPress={onClose} onPress={onClose}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'

View File

@@ -0,0 +1,135 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Platform, TouchableOpacity } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
export type ScaleFactor =
| 1.0
| 1.1
| 1.2
| 1.3
| 1.4
| 1.5
| 1.6
| 1.7
| 1.8
| 1.9
| 2.0;
interface ScaleFactorSelectorProps {
currentScale: ScaleFactor;
onScaleChange: (scale: ScaleFactor) => void;
disabled?: boolean;
}
interface ScaleFactorOption {
id: ScaleFactor;
label: string;
description: string;
}
const SCALE_FACTOR_OPTIONS: ScaleFactorOption[] = [
{
id: 1.0,
label: "1.0x",
description: "Original size",
},
{
id: 1.1,
label: "1.1x",
description: "10% larger",
},
{
id: 1.2,
label: "1.2x",
description: "20% larger",
},
{
id: 1.3,
label: "1.3x",
description: "30% larger",
},
{
id: 1.4,
label: "1.4x",
description: "40% larger",
},
{
id: 1.5,
label: "1.5x",
description: "50% larger",
},
{
id: 1.6,
label: "1.6x",
description: "60% larger",
},
{
id: 1.7,
label: "1.7x",
description: "70% larger",
},
{
id: 1.8,
label: "1.8x",
description: "80% larger",
},
{
id: 1.9,
label: "1.9x",
description: "90% larger",
},
{
id: 2.0,
label: "2.0x",
description: "Double size",
},
];
export const ScaleFactorSelector: React.FC<ScaleFactorSelectorProps> = ({
currentScale,
onScaleChange,
disabled = false,
}) => {
const lightHapticFeedback = useHaptic("light");
// Hide on TV platforms since zeego doesn't support TV
if (Platform.isTV || !DropdownMenu) return null;
const handleScaleSelect = (scale: ScaleFactor) => {
onScaleChange(scale);
lightHapticFeedback();
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<TouchableOpacity
disabled={disabled}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
style={{ opacity: disabled ? 0.5 : 1 }}
>
<Ionicons name='search-outline' size={24} color='white' />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>Scale Factor</DropdownMenu.Label>
<DropdownMenu.Separator />
{SCALE_FACTOR_OPTIONS.map((option) => (
<DropdownMenu.CheckboxItem
key={option.id}
value={currentScale === option.id ? "on" : "off"}
onValueChange={() => handleScaleSelect(option.id)}
>
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -0,0 +1,97 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Platform, TouchableOpacity } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
interface AspectRatioSelectorProps {
currentRatio: AspectRatio;
onRatioChange: (ratio: AspectRatio) => void;
disabled?: boolean;
}
interface AspectRatioOption {
id: AspectRatio;
label: string;
description: string;
}
const ASPECT_RATIO_OPTIONS: AspectRatioOption[] = [
{
id: "default",
label: "Original",
description: "Use video's original aspect ratio",
},
{
id: "16:9",
label: "16:9",
description: "Widescreen (most common)",
},
{
id: "4:3",
label: "4:3",
description: "Traditional TV format",
},
{
id: "1:1",
label: "1:1",
description: "Square format",
},
{
id: "21:9",
label: "21:9",
description: "Ultra-wide cinematic",
},
];
export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
currentRatio,
onRatioChange,
disabled = false,
}) => {
const lightHapticFeedback = useHaptic("light");
// Hide on TV platforms since zeego doesn't support TV
if (Platform.isTV || !DropdownMenu) return null;
const handleRatioSelect = (ratio: AspectRatio) => {
onRatioChange(ratio);
lightHapticFeedback();
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<TouchableOpacity
disabled={disabled}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
style={{ opacity: disabled ? 0.5 : 1 }}
>
<Ionicons name='crop-outline' size={24} color='white' />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>Aspect Ratio</DropdownMenu.Label>
<DropdownMenu.Separator />
{ASPECT_RATIO_OPTIONS.map((option) => (
<DropdownMenu.CheckboxItem
key={option.id}
value={currentRatio === option.id ? "on" : "off"}
onValueChange={() => handleRatioSelect(option.id)}
>
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
<DropdownMenu.ItemSubtitle>
{option.description}
</DropdownMenu.ItemSubtitle>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -92,7 +92,9 @@ export interface VlcPlayerViewRef {
nextChapter: () => Promise<void>; nextChapter: () => Promise<void>;
previousChapter: () => Promise<void>; previousChapter: () => Promise<void>;
getChapters: () => Promise<ChapterInfo[] | null>; getChapters: () => Promise<ChapterInfo[] | null>;
setVideoCropGeometry: (geometry: string | null) => Promise<void>; setVideoCropGeometry: (cropGeometry: string | null) => Promise<void>;
getVideoCropGeometry: () => Promise<string | null>; getVideoCropGeometry: () => Promise<string | null>;
setSubtitleURL: (url: string) => Promise<void>; setSubtitleURL: (url: string) => Promise<void>;
setVideoAspectRatio: (aspectRatio: string | null) => Promise<void>;
setVideoScaleFactor: (scaleFactor: number) => Promise<void>;
} }

View File

@@ -86,6 +86,12 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
setSubtitleURL: async (url: string) => { setSubtitleURL: async (url: string) => {
await nativeRef.current?.setSubtitleURL(url); await nativeRef.current?.setSubtitleURL(url);
}, },
setVideoAspectRatio: async (aspectRatio: string | null) => {
await nativeRef.current?.setVideoAspectRatio(aspectRatio);
},
setVideoScaleFactor: async (scaleFactor: number) => {
await nativeRef.current?.setVideoScaleFactor(scaleFactor);
},
})); }));
const { const {

View File

@@ -82,6 +82,14 @@ class VlcPlayerModule : Module() {
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String -> AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
view.setSubtitleURL(url, name) view.setSubtitleURL(url, name)
} }
AsyncFunction("setVideoAspectRatio") { view: VlcPlayerView, aspectRatio: String? ->
view.setVideoAspectRatio(aspectRatio)
}
AsyncFunction("setVideoScaleFactor") { view: VlcPlayerView, scaleFactor: Float ->
view.setVideoScaleFactor(scaleFactor)
}
} }
} }
} }

View File

@@ -335,6 +335,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true) mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
} }
fun setVideoAspectRatio(aspectRatio: String?) {
log.debug("Setting video aspect ratio: $aspectRatio")
mediaPlayer?.aspectRatio = aspectRatio
}
fun setVideoScaleFactor(scaleFactor: Float) {
log.debug("Setting video scale factor: $scaleFactor")
mediaPlayer?.scale = scaleFactor
}
private fun setInitialExternalSubtitles() { private fun setInitialExternalSubtitles() {
externalSubtitles?.let { subtitles -> externalSubtitles?.let { subtitles ->
for (subtitle in subtitles) { for (subtitle in subtitles) {

View File

@@ -62,6 +62,14 @@ public class VlcPlayerModule: Module {
view.setSubtitleTrack(trackIndex) view.setSubtitleTrack(trackIndex)
} }
AsyncFunction("setVideoAspectRatio") { (view: VlcPlayerView, aspectRatio: String?) in
view.setVideoAspectRatio(aspectRatio)
}
AsyncFunction("setVideoScaleFactor") { (view: VlcPlayerView, scaleFactor: Float) in
view.setVideoScaleFactor(scaleFactor)
}
AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
return view.getSubtitleTracks() return view.getSubtitleTracks()
} }

View File

@@ -243,6 +243,26 @@ class VlcPlayerView: ExpoView {
return tracks return tracks
} }
@objc func setVideoAspectRatio(_ aspectRatio: String?) {
DispatchQueue.main.async {
if let aspectRatio = aspectRatio {
// Convert String to C string for VLC
let cString = strdup(aspectRatio)
self.mediaPlayer?.videoAspectRatio = cString
} else {
// Reset to default (let VLC determine aspect ratio)
self.mediaPlayer?.videoAspectRatio = nil
}
}
}
@objc func setVideoScaleFactor(_ scaleFactor: Float) {
DispatchQueue.main.async {
self.mediaPlayer?.scaleFactor = scaleFactor
print("Set video scale factor: \(scaleFactor)")
}
}
@objc func stop(completion: (() -> Void)? = nil) { @objc func stop(completion: (() -> Void)? = nil) {
guard !isStopping else { guard !isStopping else {
completion?() completion?()