Compare commits

...

1 Commits

Author SHA1 Message Date
herrrta
7ea2d81fb4 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.
2025-02-23 12:57:42 -05:00
17 changed files with 305 additions and 94 deletions

View File

@@ -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<VlcPlayerViewRef>(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<VLCRendererItem[]>([]);
const discoveryModal = useRef<BottomSheetModal>(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() {
/>
</View>
{videoRef.current && !isPipStarted && isMounted === true ? (
<Controls
mediaSource={stream?.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
isVlc
/>
<>
<Controls
mediaSource={stream?.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
startDiscovery={startDiscovery}
isVlc
/>
<BottomSheetModal
ref={discoveryModal}
enableDynamicSizing
enableDismissOnClose
snapPoints={["100%"]}
onDismiss={() => {
videoRef.current?.stopDiscovery?.()
videoRef.current?.play?.()
}}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(sheetProps: BottomSheetBackdropProps) =>
<BottomSheetBackdrop
{...sheetProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
}
>
<BottomSheetView>
<ListGroup title={t("player.device_discovery")} className="mt-4 h-1/3">
{rendererItems.map((renderItem, index) => (
<ListItem
onPress={() => {
// todo: set renderer item on player to change to device
}}
icon="cast"
title={renderItem.name}
key={index}
/>
))}
</ListGroup>
</BottomSheetView>
</BottomSheetModal>
</>
) : null}
</View>
);

View File

@@ -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 = ({
<View className="flex flex-row items-center w-full">
{icon && (
<View className="border border-neutral-800 rounded-md h-8 w-8 flex items-center justify-center mr-2">
<Ionicons name="person-circle-outline" size={18} color="white" />
{icon in Ionicons.glyphMap ?
<Ionicons
name={icon as keyof typeof Ionicons.glyphMap}
size={18}
color="white"
/>
:
<MaterialCommunityIcons
name={icon as keyof typeof MaterialCommunityIcons.glyphMap}
size={18}
color="white"
/>
}
</View>
)}
<Text

View File

@@ -80,6 +80,7 @@ interface Props {
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture: () => Promise<void>;
startDiscovery: () => Promise<void>;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
@@ -93,32 +94,33 @@ interface Props {
const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({
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<Props> = ({
)}
<View className="flex flex-row items-center space-x-2 ">
<TouchableOpacity
onPress={startDiscovery}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<MaterialIcons
name="cast"
size={24}
color="white"
style={{ opacity: showControls ? 1 : 0 }}
/>
</TouchableOpacity>
{!Platform.isTV && (
<TouchableOpacity
onPress={startPictureInPicture}

View File

@@ -23,7 +23,8 @@ public class VlcPlayerModule: Module {
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError",
"onPipStarted"
"onPipStarted",
"onDiscoveryStateChanged"
)
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
@@ -42,6 +43,14 @@ public class VlcPlayerModule: Module {
view.stop()
}
AsyncFunction("startDiscovery") { (view: VlcPlayerView) in
view.startDiscovery()
}
AsyncFunction("stopDiscovery") { (view: VlcPlayerView) in
view.stopDiscovery()
}
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Int32) in
view.seekTo(time)
}

View File

@@ -26,11 +26,15 @@ public class VLCPlayerView: UIView {
}
class VLCPlayerWrapper: NSObject {
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VLCPlayerWrapper")
private var lastProgressCall = Date().timeIntervalSince1970
public var player: VLCMediaPlayer = VLCMediaPlayer()
private var updatePlayerState: (() -> 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 {

View File

@@ -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<void>;
stopDiscovery: () => Promise<void>;
startPictureInPicture: () => Promise<void>;
play: () => Promise<void>;
pause: () => Promise<void>;

View File

@@ -23,6 +23,12 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
const nativeRef = React.useRef<NativeViewRef>(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<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoLoadEnd,
onVideoError,
onPipStarted,
onDiscoveryStateChanged,
...otherProps
} = props;
@@ -127,6 +134,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoProgress={onVideoProgress}
onVideoError={onVideoError}
onPipStarted={onPipStarted}
onDiscoveryStateChanged={onDiscoveryStateChanged}
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -351,7 +351,8 @@
"audio_tracks": "音声トラック:",
"playback_state": "再生状態:",
"no_data_available": "データなし",
"index": "インデックス:"
"index": "インデックス:",
"device_discovery": "Device discovery"
},
"item_card": {
"next_up": "次",

View File

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

View File

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

View File

@@ -351,7 +351,8 @@
"audio_tracks": "音频轨道:",
"playback_state": "播放状态:",
"no_data_available": "无可用数据",
"index": "索引:"
"index": "索引:",
"device_discovery": "Device discovery"
},
"item_card": {
"next_up": "下一个",

View File

@@ -351,7 +351,8 @@
"audio_tracks": "音頻軌道:",
"playback_state": "播放狀態:",
"no_data_available": "無可用數據",
"index": "索引:"
"index": "索引:",
"device_discovery": "Device discovery"
},
"item_card": {
"next_up": "下一個",