diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 10200354..717667f5 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -16,7 +16,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Platform, View } from "react-native"; import { useSharedValue } from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; + import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; @@ -38,12 +38,9 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { writeToLog } from "@/utils/log"; -import { storage } from "@/utils/mmkv"; import { generateDeviceProfile } from "@/utils/profiles/native"; import { msToTicks, ticksToSeconds } from "@/utils/time"; -const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas"; - export default function page() { const videoRef = useRef(null); const user = useAtomValue(userAtom); @@ -53,11 +50,12 @@ export default function page() { const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [showControls, _setShowControls] = useState(true); - const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => { - // Load persisted state from storage - const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY); - return saved ?? false; - }); + const [aspectRatio, setAspectRatio] = useState< + "default" | "16:9" | "4:3" | "1:1" | "21:9" + >("default"); + 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 [isMuted, setIsMuted] = useState(false); const [isBuffering, setIsBuffering] = useState(true); @@ -82,11 +80,6 @@ export default function page() { lightHapticFeedback(); }, []); - // Persist ignoreSafeAreas state whenever it changes - useEffect(() => { - storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas); - }, [ignoreSafeAreas]); - const { itemId, audioIndex: audioIndexStr, @@ -106,7 +99,7 @@ export default function page() { playbackPosition?: string; }>(); const [settings] = useSettings(); - const insets = useSafeAreaInsets(); + const offline = offlineStr === "true"; const playbackManager = usePlaybackManager(); @@ -571,7 +564,14 @@ export default function page() { ); return ( - + {})} + pause={videoRef.current?.pause || (() => {})} + seek={videoRef.current?.seekTo || (() => {})} enableTrickplay={true} getAudioTracks={videoRef.current?.getAudioTracks} getSubtitleTracks={videoRef.current?.getSubtitleTracks} @@ -639,6 +635,12 @@ export default function page() { setSubtitleTrack={videoRef.current?.setSubtitleTrack} setSubtitleURL={videoRef.current?.setSubtitleURL} setAudioTrack={videoRef.current?.setAudioTrack} + setVideoAspectRatio={videoRef.current?.setVideoAspectRatio} + setVideoScaleFactor={videoRef.current?.setVideoScaleFactor} + aspectRatio={aspectRatio} + scaleFactor={scaleFactor} + setAspectRatio={setAspectRatio} + setScaleFactor={setScaleFactor} isVlc /> )} diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index d825d8ee..d8f660d7 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -59,8 +59,13 @@ import { VideoProvider } from "./contexts/VideoContext"; import DropdownView from "./dropdown/DropdownView"; import { EpisodeList } from "./EpisodeList"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; +import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector"; import SkipButton from "./SkipButton"; import { useControlsTimeout } from "./useControlsTimeout"; +import { + type AspectRatio, + AspectRatioSelector, +} from "./VideoScalingModeSelector"; import { VideoTouchOverlay } from "./VideoTouchOverlay"; interface Props { @@ -72,8 +77,7 @@ interface Props { progress: SharedValue; isBuffering: boolean; showControls: boolean; - ignoreSafeAreas?: boolean; - setIgnoreSafeAreas: Dispatch>; + enableTrickplay?: boolean; togglePlay: () => void; setShowControls: (shown: boolean) => void; @@ -89,6 +93,12 @@ interface Props { setSubtitleURL?: (url: string, customName: string) => void; setSubtitleTrack?: (index: number) => void; setAudioTrack?: (index: number) => void; + setVideoAspectRatio?: (aspectRatio: string | null) => Promise; + setVideoScaleFactor?: (scaleFactor: number) => Promise; + aspectRatio?: AspectRatio; + scaleFactor?: ScaleFactor; + setAspectRatio?: Dispatch>; + setScaleFactor?: Dispatch>; isVlc?: boolean; } @@ -108,8 +118,6 @@ export const Controls: FC = ({ cacheProgress, showControls, setShowControls, - ignoreSafeAreas, - setIgnoreSafeAreas, mediaSource, isVideoLoaded, getAudioTracks, @@ -117,6 +125,12 @@ export const Controls: FC = ({ setSubtitleURL, setSubtitleTrack, setAudioTrack, + setVideoAspectRatio, + setVideoScaleFactor, + aspectRatio = "default", + scaleFactor = 1.0, + setAspectRatio, + setScaleFactor, offline = false, isVlc = false, }) => { @@ -631,10 +645,26 @@ export const Controls: FC = ({ } }, [settings, isPlaying, isVlc, play, seek]); - const toggleIgnoreSafeAreas = useCallback(() => { - setIgnoreSafeAreas((prev) => !prev); - lightHapticFeedback(); - }, []); + const handleAspectRatioChange = useCallback( + async (newRatio: AspectRatio) => { + 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(() => { setEpisodeView(true); @@ -801,17 +831,17 @@ export const Controls: FC = ({ )} - {/* {mediaSource?.TranscodingUrl && ( */} - - - + {/* Video Controls */} + + 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 = ({ + 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 ( + + + + + + + + + Scale Factor + + + {SCALE_FACTOR_OPTIONS.map((option) => ( + handleScaleSelect(option.id)} + > + {option.label} + + + ))} + + + ); +}; diff --git a/components/video-player/controls/VideoScalingModeSelector.tsx b/components/video-player/controls/VideoScalingModeSelector.tsx new file mode 100644 index 00000000..82874abf --- /dev/null +++ b/components/video-player/controls/VideoScalingModeSelector.tsx @@ -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 = ({ + 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 ( + + + + + + + + + Aspect Ratio + + + {ASPECT_RATIO_OPTIONS.map((option) => ( + handleRatioSelect(option.id)} + > + {option.label} + + {option.description} + + + + ))} + + + ); +}; diff --git a/modules/VlcPlayer.types.ts b/modules/VlcPlayer.types.ts index 9bdd0afd..2f4e34b0 100644 --- a/modules/VlcPlayer.types.ts +++ b/modules/VlcPlayer.types.ts @@ -92,7 +92,9 @@ export interface VlcPlayerViewRef { nextChapter: () => Promise; previousChapter: () => Promise; getChapters: () => Promise; - setVideoCropGeometry: (geometry: string | null) => Promise; + setVideoCropGeometry: (cropGeometry: string | null) => Promise; getVideoCropGeometry: () => Promise; setSubtitleURL: (url: string) => Promise; + setVideoAspectRatio: (aspectRatio: string | null) => Promise; + setVideoScaleFactor: (scaleFactor: number) => Promise; } diff --git a/modules/VlcPlayerView.tsx b/modules/VlcPlayerView.tsx index 24c0d0ff..8591b2ac 100644 --- a/modules/VlcPlayerView.tsx +++ b/modules/VlcPlayerView.tsx @@ -86,6 +86,12 @@ const VlcPlayerView = React.forwardRef( setSubtitleURL: async (url: string) => { 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 { diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt index 91d48fc5..21c953e1 100644 --- a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt +++ b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt @@ -82,6 +82,14 @@ class VlcPlayerModule : Module() { AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String -> view.setSubtitleURL(url, name) } + + AsyncFunction("setVideoAspectRatio") { view: VlcPlayerView, aspectRatio: String? -> + view.setVideoAspectRatio(aspectRatio) + } + + AsyncFunction("setVideoScaleFactor") { view: VlcPlayerView, scaleFactor: Float -> + view.setVideoScaleFactor(scaleFactor) + } } } } \ No newline at end of file diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt index 2a67cf26..c4517802 100644 --- a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt +++ b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt @@ -335,6 +335,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context 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() { externalSubtitles?.let { subtitles -> for (subtitle in subtitles) { diff --git a/modules/vlc-player/ios/VlcPlayerModule.swift b/modules/vlc-player/ios/VlcPlayerModule.swift index 14e8f01a..9dc04d5a 100644 --- a/modules/vlc-player/ios/VlcPlayerModule.swift +++ b/modules/vlc-player/ios/VlcPlayerModule.swift @@ -62,6 +62,14 @@ public class VlcPlayerModule: Module { 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 return view.getSubtitleTracks() } diff --git a/modules/vlc-player/ios/VlcPlayerView.swift b/modules/vlc-player/ios/VlcPlayerView.swift index dbedfa2b..1cc30110 100644 --- a/modules/vlc-player/ios/VlcPlayerView.swift +++ b/modules/vlc-player/ios/VlcPlayerView.swift @@ -243,6 +243,26 @@ class VlcPlayerView: ExpoView { 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) { guard !isStopping else { completion?()