From 7ea2d81fb46cb6f94a780b37719d9b87cc6f92bc Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:28:40 -0500 Subject: [PATCH] feat: Use VLC discovery API for chromecast and other devices Currently, this only works for chromecast. In the future, this implementation will also work for other types like upnp & airplay. --- app/(auth)/player/direct-player.tsx | 136 +++++++++++++----- components/list/ListItem.tsx | 18 ++- components/video-player/controls/Controls.tsx | 65 +++++---- modules/vlc-player/ios/VlcPlayerModule.swift | 11 +- modules/vlc-player/ios/VlcPlayerView.swift | 116 ++++++++++++--- modules/vlc-player/src/VlcPlayer.types.ts | 15 ++ modules/vlc-player/src/VlcPlayerView.tsx | 8 ++ translations/de.json | 3 +- translations/en.json | 3 +- translations/es.json | 3 +- translations/fr.json | 3 +- translations/it.json | 3 +- translations/ja.json | 3 +- translations/nl.json | 3 +- translations/tr.json | 3 +- translations/zh-CN.json | 3 +- translations/zh-TW.json | 3 +- 17 files changed, 305 insertions(+), 94 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 51d5c3dc..c1178b8f 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -7,10 +7,11 @@ import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybac import { useWebSocket } from "@/hooks/useWebsockets"; import { VlcPlayerView } from "@/modules/vlc-player"; import { + OnDiscoveryStateChangedPayload, PipStartedPayload, PlaybackStatePayload, ProgressUpdatePayload, - VlcPlayerViewRef, + VlcPlayerViewRef, VLCRendererItem, } from "@/modules/vlc-player/src/VlcPlayer.types"; const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -30,6 +31,11 @@ import { useSettings } from "@/utils/atoms/settings"; import { useTranslation } from "react-i18next"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { BaseItemDto, MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client"; +import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet"; +import {ListGroup} from "@/components/list/ListGroup"; +import {ListItem} from "@/components/list/ListItem"; +import {storage} from "@/utils/mmkv"; +import {t} from "i18next"; export default function page() { const videoRef = useRef(null); @@ -45,6 +51,8 @@ export default function page() { const [isBuffering, setIsBuffering] = useState(true); const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [isPipStarted, setIsPipStarted] = useState(false); + const [rendererItems, setRendererItems] = useState([]); + const discoveryModal = useRef(null); const progress = useSharedValue(0); const isSeeking = useSharedValue(false); @@ -243,11 +251,6 @@ export default function page() { [item?.Id, audioIndex, subtitleIndex, mediaSourceId, isPlaying, stream, isSeeking, isPlaybackStopped, isBuffering] ); - const onPipStarted = useCallback((e: PipStartedPayload) => { - const { pipStarted } = e.nativeEvent; - setIsPipStarted(pipStarted); - }, []); - const changePlaybackState = useCallback( async (isPlaying: boolean) => { if (!api || offline || !stream) return; @@ -298,9 +301,25 @@ export default function page() { offline, }); - const onPlaybackStateChanged = useCallback( - async (e: PlaybackStatePayload) => { - const { state, isBuffering, isPlaying } = e.nativeEvent; + const onPipStarted = useCallback((e: PipStartedPayload) => { + const { pipStarted } = e.nativeEvent; + setIsPipStarted(pipStarted); + }, []); + + const onDiscoveryStateChanged = useCallback((e: OnDiscoveryStateChangedPayload) => { + const {renderers} = e.nativeEvent; + setRendererItems(renderers); + }, []); + + const startDiscovery = useCallback(async () => { + videoRef?.current?.pause?.() + videoRef?.current?.stopDiscovery?.() + videoRef?.current?.startDiscovery?.() + discoveryModal?.current?.present?.() + }, [rendererItems, videoRef]) + + const onPlaybackStateChanged = useCallback(async (e: PlaybackStatePayload) => { + const { state, isBuffering, isPlaying } = e.nativeEvent; if (state === "Playing") { setIsPlaying(true); @@ -409,6 +428,7 @@ export default function page() { progressUpdateInterval={1000} onVideoStateChange={onPlaybackStateChanged} onPipStarted={onPipStarted} + onDiscoveryStateChanged={onDiscoveryStateChanged} onVideoLoadEnd={() => { setIsVideoLoaded(true); }} @@ -420,34 +440,76 @@ export default function page() { /> {videoRef.current && !isPipStarted && isMounted === true ? ( - + <> + + { + videoRef.current?.stopDiscovery?.() + videoRef.current?.play?.() + }} + handleIndicatorStyle={{ + backgroundColor: "white", + }} + backgroundStyle={{ + backgroundColor: "#171717", + }} + backdropComponent={(sheetProps: BottomSheetBackdropProps) => + + } + > + + + {rendererItems.map((renderItem, index) => ( + { + // todo: set renderer item on player to change to device + }} + icon="cast" + title={renderItem.name} + key={index} + /> + ))} + + + + + ) : null} ); diff --git a/components/list/ListItem.tsx b/components/list/ListItem.tsx index ea7774a4..d7493a4c 100644 --- a/components/list/ListItem.tsx +++ b/components/list/ListItem.tsx @@ -1,4 +1,4 @@ -import { Ionicons } from "@expo/vector-icons"; +import {Ionicons, MaterialCommunityIcons} from "@expo/vector-icons"; import { PropsWithChildren, ReactNode } from "react"; import { TouchableOpacity, @@ -13,7 +13,7 @@ interface Props extends TouchableOpacityProps, ViewProps { value?: string | null | undefined; children?: ReactNode; iconAfter?: ReactNode; - icon?: keyof typeof Ionicons.glyphMap; + icon?: keyof typeof Ionicons.glyphMap | keyof typeof MaterialCommunityIcons.glyphMap; showArrow?: boolean; textColor?: "default" | "blue" | "red"; onPress?: () => void; @@ -89,7 +89,19 @@ const ListItemContent = ({ {icon && ( - + {icon in Ionicons.glyphMap ? + + : + + } )} void; startPictureInPicture: () => Promise; + startDiscovery: () => Promise; play: (() => Promise) | (() => void); pause: () => void; getAudioTracks?: (() => Promise) | (() => TrackInfo[]); @@ -93,32 +94,33 @@ interface Props { const CONTROLS_TIMEOUT = 4000; export const Controls: React.FC = ({ - item, - seek, - startPictureInPicture, - play, - pause, - togglePlay, - isPlaying, - isSeeking, - progress, - isBuffering, - cacheProgress, - showControls, - setShowControls, - ignoreSafeAreas, - setIgnoreSafeAreas, - mediaSource, - isVideoLoaded, - getAudioTracks, - getSubtitleTracks, - setSubtitleURL, - setSubtitleTrack, - setAudioTrack, - offline = false, - enableTrickplay = true, - isVlc = false, - }) => { + item, + seek, + startDiscovery, + startPictureInPicture, + play, + pause, + togglePlay, + isPlaying, + isSeeking, + progress, + isBuffering, + cacheProgress, + showControls, + setShowControls, + ignoreSafeAreas, + setIgnoreSafeAreas, + mediaSource, + isVideoLoaded, + getAudioTracks, + getSubtitleTracks, + setSubtitleURL, + setSubtitleTrack, + setAudioTrack, + offline = false, + enableTrickplay = true, + isVlc = false, +}) => { const [settings] = useSettings(); const router = useRouter(); const insets = useSafeAreaInsets(); @@ -494,6 +496,17 @@ export const Controls: React.FC = ({ )} + + + {!Platform.isTV && ( Void)? private var updateVideoProgress: (() -> Void)? + private var onDiscoveryStateChanged: ((_ renderers: [[String : Any]]) -> Void)? private var playerView: VLCPlayerView = VLCPlayerView() + public var discoverer: VLCRendererDiscoverer? public weak var pipController: VLCPictureInPictureWindowControlling? override public init() { @@ -38,15 +42,22 @@ class VLCPlayerWrapper: NSObject { player.delegate = self player.drawable = self player.scaleFactor = 0 +#if DEBUG + let consoleLogger = VLCConsoleLogger() + consoleLogger.level = VLCLogLevel.debug + player.libraryInstance.loggers = [consoleLogger] +#endif } public func setup( parent: UIView, updatePlayerState: (() -> Void)?, - updateVideoProgress: (() -> Void)? + updateVideoProgress: (() -> Void)?, + onDiscoveryStateChanged: ((_ renderers: [[String : Any]]) -> Void)? ) { self.updatePlayerState = updatePlayerState self.updateVideoProgress = updateVideoProgress + self.onDiscoveryStateChanged = onDiscoveryStateChanged player.delegate = self parent.addSubview(playerView) @@ -56,6 +67,50 @@ class VLCPlayerWrapper: NSObject { public func getPlayerView() -> UIView { return playerView } + + public func startDiscovery() { + if self.discoverer != nil { + self.discoverer!.stop() + self.discoverer!.start() + return + } + let _discoverer = VLCRendererDiscoverer(name: "bonjour renderer") + _discoverer!.delegate = self + + self.discoverer = _discoverer + self.discoverer?.start() + } + + public func stopDiscovery() { + guard let discoverer = self.discoverer else { return } + discoverer.stop() + } +} + +extension VLCPlayerWrapper: VLCRendererDiscovererDelegate { + func rendererDiscovererItemAdded(_ rendererDiscoverer: VLCRendererDiscoverer?, item: VLCRendererItem?) { + logger.debug("Renderer item added: \(item)") + self.onDiscoveryStateChanged?(getRenderersMap(rendererDiscoverer: rendererDiscoverer)) + } + + func rendererDiscovererItemDeleted(_ rendererDiscoverer: VLCRendererDiscoverer?, item: VLCRendererItem?) { + logger.debug("Renderer item removed: \(item)") + self.onDiscoveryStateChanged?(getRenderersMap(rendererDiscoverer: rendererDiscoverer)) + } + + private func getRenderersMap(rendererDiscoverer: VLCRendererDiscoverer?) -> [[String : Any]] { + let renderers = (rendererDiscoverer ?? discoverer)?.renderers.enumerated().map { (index, rendererItem) in + return [ + "index": index, + "name": rendererItem.name, + "type": rendererItem.type, + "iconURI": rendererItem.iconURI, + "flags": rendererItem.flags + ] + } ?? [] + logger.debug("Renderers mapped to: \(renderers)") + return renderers + } } // MARK: - VLCPictureInPictureDrawable @@ -156,6 +211,16 @@ class VlcPlayerView: ExpoView { private var externalSubtitles: [[String: String]]? var hasSource = false + // MARK: - Expo Events + @objc var onPlaybackStateChanged: RCTDirectEventBlock? + @objc var onVideoLoadStart: RCTDirectEventBlock? + @objc var onVideoStateChange: RCTDirectEventBlock? + @objc var onVideoProgress: RCTDirectEventBlock? + @objc var onVideoLoadEnd: RCTDirectEventBlock? + @objc var onVideoError: RCTDirectEventBlock? + @objc var onPipStarted: RCTDirectEventBlock? + @objc var onDiscoveryStateChanged: RCTDirectEventBlock? + // MARK: - Initialization required init(appContext: AppContext? = nil) { super.init(appContext: appContext) @@ -169,10 +234,15 @@ class VlcPlayerView: ExpoView { vlc.setup( parent: self, updatePlayerState: updatePlayerState, - updateVideoProgress: updateVideoProgress + updateVideoProgress: updateVideoProgress, + onDiscoveryStateChanged: updateDiscoveryState ) } + private func updateDiscoveryState(renderers: [[String: Any]]) { + self.onDiscoveryStateChanged?(["renderers": renderers]) + } + private func setupNotifications() { NotificationCenter.default.addObserver( self, selector: #selector(applicationWillResignActive), @@ -190,6 +260,19 @@ class VlcPlayerView: ExpoView { self.vlc.pipController?.startPictureInPicture() } + func startDiscovery() { + logger.debug("Starting Discovery") + self.vlc.startDiscovery() + if self.vlc.discoverer != nil { + logger.debug("Discoverer description: \(self.vlc.discoverer!.description)") + logger.debug("Discoverer renderer: \(self.vlc.discoverer!.renderers)") + } + } + + func stopDiscovery() { + self.vlc.stopDiscovery() + } + @objc func play() { self.vlc.player.play() self.isPaused = false @@ -240,12 +323,6 @@ class VlcPlayerView: ExpoView { self.startPosition = source["startPosition"] as? Int32 ?? 0 self.externalSubtitles = source["externalSubtitles"] as? [[String: String]] - for item in initOptions { - let option = item.components(separatedBy: "=") - mediaOptions.updateValue( - option[1], forKey: option[0].replacingOccurrences(of: "--", with: "")) - } - guard let uri = source["uri"] as? String, !uri.isEmpty else { logger.error("Invalid or empty URI") self.onVideoError?(["error": "Invalid or empty URI"]) @@ -270,6 +347,20 @@ class VlcPlayerView: ExpoView { } } + for item in initOptions { + let option = item.components(separatedBy: "=") + var key = option[0].replacingOccurrences(of: "--", with: "") + if option.count > 1 { + mediaOptions.updateValue( + option[1], + forKey: key + ) + } + else { + media.addOption(key) + } + } + logger.debug("Media options: \(mediaOptions)") media.addOptions(mediaOptions) @@ -427,15 +518,6 @@ class VlcPlayerView: ExpoView { ]) } - // MARK: - Expo Events - @objc var onPlaybackStateChanged: RCTDirectEventBlock? - @objc var onVideoLoadStart: RCTDirectEventBlock? - @objc var onVideoStateChange: RCTDirectEventBlock? - @objc var onVideoProgress: RCTDirectEventBlock? - @objc var onVideoLoadEnd: RCTDirectEventBlock? - @objc var onVideoError: RCTDirectEventBlock? - @objc var onPipStarted: RCTDirectEventBlock? - // MARK: - Deinitialization deinit { diff --git a/modules/vlc-player/src/VlcPlayer.types.ts b/modules/vlc-player/src/VlcPlayer.types.ts index e1c37797..f4e4c49e 100644 --- a/modules/vlc-player/src/VlcPlayer.types.ts +++ b/modules/vlc-player/src/VlcPlayer.types.ts @@ -30,6 +30,18 @@ export type PipStartedPayload = { }; }; +export type VLCRendererItem = { + index: number, + name: string, + type: string, + iconURI: string, + flags: number +} + +export type OnDiscoveryStateChangedPayload = { + nativeEvent: { renderers: VLCRendererItem[] } +} + export type VideoStateChangePayload = PlaybackStatePayload; export type VideoProgressPayload = ProgressUpdatePayload; @@ -71,9 +83,12 @@ export type VlcPlayerViewProps = { onVideoLoadEnd?: (event: VideoLoadStartPayload) => void; onVideoError?: (event: PlaybackStatePayload) => void; onPipStarted?: (event: PipStartedPayload) => void; + onDiscoveryStateChanged?: (event: OnDiscoveryStateChangedPayload) => void; }; export interface VlcPlayerViewRef { + startDiscovery: () => Promise; + stopDiscovery: () => Promise; startPictureInPicture: () => Promise; play: () => Promise; pause: () => Promise; diff --git a/modules/vlc-player/src/VlcPlayerView.tsx b/modules/vlc-player/src/VlcPlayerView.tsx index 8195d6a9..05740281 100644 --- a/modules/vlc-player/src/VlcPlayerView.tsx +++ b/modules/vlc-player/src/VlcPlayerView.tsx @@ -23,6 +23,12 @@ const VlcPlayerView = React.forwardRef( const nativeRef = React.useRef(null); React.useImperativeHandle(ref, () => ({ + startDiscovery: async () => { + await nativeRef.current?.startDiscovery() + }, + stopDiscovery: async () => { + await nativeRef.current?.stopDiscovery() + }, startPictureInPicture: async () => { await nativeRef.current?.startPictureInPicture() }, @@ -100,6 +106,7 @@ const VlcPlayerView = React.forwardRef( onVideoLoadEnd, onVideoError, onPipStarted, + onDiscoveryStateChanged, ...otherProps } = props; @@ -127,6 +134,7 @@ const VlcPlayerView = React.forwardRef( onVideoProgress={onVideoProgress} onVideoError={onVideoError} onPipStarted={onPipStarted} + onDiscoveryStateChanged={onDiscoveryStateChanged} /> ); } diff --git a/translations/de.json b/translations/de.json index 18c3de25..028226c7 100644 --- a/translations/de.json +++ b/translations/de.json @@ -352,7 +352,8 @@ "audio_tracks": "Audiospuren:", "playback_state": "Wiedergabestatus:", "no_data_available": "Keine Daten verfügbar", - "index": "Index:" + "index": "Index:", + "device_discovery": "Device discovery" }, "item_card": { "next_up": "Als Nächstes", diff --git a/translations/en.json b/translations/en.json index 615c3e40..66dcc19e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -356,7 +356,8 @@ "audio_tracks": "Audio Tracks:", "playback_state": "Playback State:", "no_data_available": "No data available", - "index": "Index:" + "index": "Index:", + "device_discovery": "Device discovery" }, "item_card": { "next_up": "Next up", diff --git a/translations/es.json b/translations/es.json index 8883c2be..e0036a94 100644 --- a/translations/es.json +++ b/translations/es.json @@ -352,7 +352,8 @@ "audio_tracks": "Pistas de audio:", "playback_state": "Estado de la reproducción:", "no_data_available": "No hay datos disponibles", - "index": "Índice:" + "index": "Índice:", + "device_discovery": "Device discovery" }, "item_card": { "next_up": "A continuación", diff --git a/translations/fr.json b/translations/fr.json index f197494d..3ec92bcb 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -353,7 +353,8 @@ "audio_tracks": "Pistes audio:", "playback_state": "État de lecture:", "no_data_available": "Aucune donnée disponible", - "index": "Index:" + "index": "Index:", + "device_discovery": "Device discovery" }, "item_card": { "next_up": "À suivre", diff --git a/translations/it.json b/translations/it.json index c9326a7d..64135492 100644 --- a/translations/it.json +++ b/translations/it.json @@ -352,7 +352,8 @@ "audio_tracks": "Tracce audio:", "playback_state": "Stato della riproduzione:", "no_data_available": "Nessun dato disponibile", - "index": "Indice:" + "index": "Indice:", + "device_discovery": "Device discovery" }, "item_card": { "next_up": "Il prossimo", diff --git a/translations/ja.json b/translations/ja.json index 2f43f5ae..8b4544a2 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -351,7 +351,8 @@ "audio_tracks": "音声トラック:", "playback_state": "再生状態:", "no_data_available": "データなし", - "index": "インデックス:" + "index": "インデックス:", + "device_discovery": "Device discovery" }, "item_card": { "next_up": "次", diff --git a/translations/nl.json b/translations/nl.json index 7ab85468..18ca64f1 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -352,7 +352,8 @@ "audio_tracks": "Audio Tracks:", "playback_state": "Afspeelstatus:", "no_data_available": "Geen data beschikbaar", - "index": "Index:" + "index": "Index:", + "device_discovery": "Device discovery" }, "item_card": { "next_up": "Volgende", diff --git a/translations/tr.json b/translations/tr.json index 7b3a2320..bf16c2a4 100644 --- a/translations/tr.json +++ b/translations/tr.json @@ -351,7 +351,8 @@ "audio_tracks": "Ses Parçaları:", "playback_state": "Oynatma Durumu:", "no_data_available": "Veri bulunamadı", - "index": "İndeks:" + "index": "İndeks:", + "device_discovery": "Device discovery" }, "item_card": { "next_up": "Sıradaki", diff --git a/translations/zh-CN.json b/translations/zh-CN.json index b501cef0..0d577c13 100644 --- a/translations/zh-CN.json +++ b/translations/zh-CN.json @@ -351,7 +351,8 @@ "audio_tracks": "音频轨道:", "playback_state": "播放状态:", "no_data_available": "无可用数据", - "index": "索引:" + "index": "索引:", + "device_discovery": "Device discovery" }, "item_card": { "next_up": "下一个", diff --git a/translations/zh-TW.json b/translations/zh-TW.json index 21800640..d210bf08 100644 --- a/translations/zh-TW.json +++ b/translations/zh-TW.json @@ -351,7 +351,8 @@ "audio_tracks": "音頻軌道:", "playback_state": "播放狀態:", "no_data_available": "無可用數據", - "index": "索引:" + "index": "索引:", + "device_discovery": "Device discovery" }, "item_card": { "next_up": "下一個",