diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 537c11a4..0d93f57f 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -43,6 +43,7 @@ import { useDownload } from "@/providers/DownloadProvider"; import { DownloadedItem } from "@/providers/Downloads/types"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { writeToLog } from "@/utils/log"; import { generateDeviceProfile } from "@/utils/profiles/native"; @@ -673,7 +674,30 @@ export default function page() { ); }, []); - console.log("Debug: component render"); // Uncomment to debug re-renders + // Prepare metadata for iOS native media controls + const nowPlayingMetadata = useMemo(() => { + if (!item || !api) return undefined; + + const artworkUri = getPrimaryImageUrl({ + api, + item, + quality: 90, + width: 500, + }); + + return { + title: item.Name || "", + artist: + item.Type === "Episode" + ? item.SeriesName || "" + : item.AlbumArtist || "", + albumTitle: + item.Type === "Episode" && item.SeasonName + ? item.SeasonName + : undefined, + artworkUri: artworkUri || undefined, + }; + }, [item, api]); // Show error UI first, before checking loading/missing‐data if (itemStatus.isError || streamStatus.isError) { @@ -731,6 +755,7 @@ export default function page() { initOptions, }} style={{ width: "100%", height: "100%" }} + nowPlayingMetadata={nowPlayingMetadata} onVideoProgress={onProgress} progressUpdateInterval={1000} onVideoStateChange={onPlaybackStateChanged} diff --git a/components/video-player/controls/SkipButton.tsx b/components/video-player/controls/SkipButton.tsx index 2e34e7b7..016f94d1 100644 --- a/components/video-player/controls/SkipButton.tsx +++ b/components/video-player/controls/SkipButton.tsx @@ -13,12 +13,6 @@ const SkipButton: React.FC = ({ buttonText, ...props }) => { - console.log(`[SKIP_BUTTON] Render:`, { - buttonText, - showButton, - className: showButton ? "flex" : "hidden", - }); - return ( = ({ useEffect(() => { const fetchTracks = async () => { if (getSubtitleTracks) { - let subtitleData = await getSubtitleTracks(); + let subtitleData: TrackInfo[] | null = null; + try { + subtitleData = await getSubtitleTracks(); + } catch (error) { + console.log("[VideoContext] Failed to get subtitle tracks:", error); + return; + } // Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles. if ( mediaSource?.TranscodingUrl && @@ -179,7 +185,13 @@ export const VideoProvider: React.FC = ({ setSubtitleTracks(subtitles); } if (getAudioTracks) { - const audioData = await getAudioTracks(); + let audioData: TrackInfo[] | null = null; + try { + audioData = await getAudioTracks(); + } catch (error) { + console.log("[VideoContext] Failed to get audio tracks:", error); + return; + } const allAudio = mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || []; const audioTracks: Track[] = allAudio?.map((audio, idx) => { diff --git a/components/vlc/VideoDebugInfo.tsx b/components/vlc/VideoDebugInfo.tsx index bfdc9051..40b74b6d 100644 --- a/components/vlc/VideoDebugInfo.tsx +++ b/components/vlc/VideoDebugInfo.tsx @@ -19,10 +19,14 @@ export const VideoDebugInfo: React.FC = ({ playerRef, ...props }) => { useEffect(() => { const fetchTracks = async () => { if (playerRef.current) { - const audio = await playerRef.current.getAudioTracks(); - const subtitles = await playerRef.current.getSubtitleTracks(); - setAudioTracks(audio); - setSubtitleTracks(subtitles); + try { + const audio = await playerRef.current.getAudioTracks(); + const subtitles = await playerRef.current.getSubtitleTracks(); + setAudioTracks(audio); + setSubtitleTracks(subtitles); + } catch (error) { + console.log("[VideoDebugInfo] Failed to fetch tracks:", error); + } } }; @@ -60,8 +64,24 @@ export const VideoDebugInfo: React.FC = ({ playerRef, ...props }) => { className='mt-2.5 bg-blue-500 p-2 rounded' onPress={() => { if (playerRef.current) { - playerRef.current.getAudioTracks().then(setAudioTracks); - playerRef.current.getSubtitleTracks().then(setSubtitleTracks); + playerRef.current + .getAudioTracks() + .then(setAudioTracks) + .catch((err) => { + console.log( + "[VideoDebugInfo] Failed to get audio tracks:", + err, + ); + }); + playerRef.current + .getSubtitleTracks() + .then(setSubtitleTracks) + .catch((err) => { + console.log( + "[VideoDebugInfo] Failed to get subtitle tracks:", + err, + ); + }); } }} > diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts index b25551d3..d11ed511 100644 --- a/hooks/useIntroSkipper.ts +++ b/hooks/useIntroSkipper.ts @@ -43,37 +43,14 @@ export const useIntroSkipper = ( const introTimestamps = segments?.introSegments?.[0]; useEffect(() => { - console.log(`[INTRO_SKIPPER] Hook state:`, { - itemId, - currentTime, - hasSegments: !!segments, - segments: segments, - introSegmentsCount: segments?.introSegments?.length || 0, - introSegments: segments?.introSegments, - hasIntroTimestamps: !!introTimestamps, - introTimestamps, - isVlc, - isOffline, - }); - if (introTimestamps) { const shouldShow = currentTime > introTimestamps.startTime && currentTime < introTimestamps.endTime; - console.log(`[INTRO_SKIPPER] Button visibility check:`, { - currentTime, - introStart: introTimestamps.startTime, - introEnd: introTimestamps.endTime, - afterStart: currentTime > introTimestamps.startTime, - beforeEnd: currentTime < introTimestamps.endTime, - shouldShow, - }); - setShowSkipButton(shouldShow); } else { if (showSkipButton) { - console.log(`[INTRO_SKIPPER] No intro timestamps, hiding button`); setShowSkipButton(false); } } @@ -82,10 +59,6 @@ export const useIntroSkipper = ( const skipIntro = useCallback(() => { if (!introTimestamps) return; try { - console.log( - `[INTRO_SKIPPER] Skipping intro to:`, - introTimestamps.endTime, - ); lightHapticFeedback(); wrappedSeek(introTimestamps.endTime); setTimeout(() => { @@ -96,7 +69,5 @@ export const useIntroSkipper = ( } }, [introTimestamps, lightHapticFeedback, wrappedSeek, play]); - console.log(`[INTRO_SKIPPER] Returning state:`, { showSkipButton }); - return { showSkipButton, skipIntro }; }; diff --git a/modules/VlcPlayer.types.ts b/modules/VlcPlayer.types.ts index 2f4e34b0..93c0923d 100644 --- a/modules/VlcPlayer.types.ts +++ b/modules/VlcPlayer.types.ts @@ -59,6 +59,13 @@ export type ChapterInfo = { duration: number; }; +export type NowPlayingMetadata = { + title?: string; + artist?: string; + albumTitle?: string; + artworkUri?: string; +}; + export type VlcPlayerViewProps = { source: VlcPlayerSource; style?: ViewStyle | ViewStyle[]; @@ -67,6 +74,7 @@ export type VlcPlayerViewProps = { muted?: boolean; volume?: number; videoAspectRatio?: string; + nowPlayingMetadata?: NowPlayingMetadata; onVideoProgress?: (event: ProgressUpdatePayload) => void; onVideoStateChange?: (event: PlaybackStatePayload) => void; onVideoLoadStart?: (event: VideoLoadStartPayload) => void; diff --git a/modules/VlcPlayerView.tsx b/modules/VlcPlayerView.tsx index 8591b2ac..a5cac3af 100644 --- a/modules/VlcPlayerView.tsx +++ b/modules/VlcPlayerView.tsx @@ -102,6 +102,7 @@ const VlcPlayerView = React.forwardRef( muted, volume, videoAspectRatio, + nowPlayingMetadata, onVideoLoadStart, onVideoStateChange, onVideoProgress, @@ -131,6 +132,7 @@ const VlcPlayerView = React.forwardRef( muted={muted} volume={volume} videoAspectRatio={videoAspectRatio} + nowPlayingMetadata={nowPlayingMetadata} onVideoLoadStart={onVideoLoadStart} onVideoLoadEnd={onVideoLoadEnd} onVideoStateChange={onVideoStateChange} diff --git a/modules/vlc-player/ios/VlcPlayerModule.swift b/modules/vlc-player/ios/VlcPlayerModule.swift index 9dc04d5a..5685ac8c 100644 --- a/modules/vlc-player/ios/VlcPlayerModule.swift +++ b/modules/vlc-player/ios/VlcPlayerModule.swift @@ -16,6 +16,12 @@ public class VlcPlayerModule: Module { } } + Prop("nowPlayingMetadata") { (view: VlcPlayerView, metadata: [String: String]?) in + if let metadata = metadata { + view.setNowPlayingMetadata(metadata) + } + } + Events( "onPlaybackStateChanged", "onVideoStateChange", diff --git a/modules/vlc-player/ios/VlcPlayerView.swift b/modules/vlc-player/ios/VlcPlayerView.swift index 1cc30110..86607d14 100644 --- a/modules/vlc-player/ios/VlcPlayerView.swift +++ b/modules/vlc-player/ios/VlcPlayerView.swift @@ -1,4 +1,6 @@ import ExpoModulesCore +import MediaPlayer +import AVFoundation #if os(tvOS) import TVVLCKit @@ -24,6 +26,9 @@ class VlcPlayerView: ExpoView { var hasSource = false var isTranscoding = false private var initialSeekPerformed: Bool = false + private var nowPlayingMetadata: [String: String]? + private var artworkImage: UIImage? + private var artworkDownloadTask: URLSessionDataTask? // MARK: - Initialization @@ -31,6 +36,8 @@ class VlcPlayerView: ExpoView { super.init(appContext: appContext) setupView() setupNotifications() + setupRemoteCommandCenter() + setupAudioSession() } // MARK: - Setup @@ -60,42 +67,205 @@ class VlcPlayerView: ExpoView { NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) + + #if !os(tvOS) + // Handle audio session interruptions (e.g., incoming calls, other apps playing audio) + NotificationCenter.default.addObserver( + self, selector: #selector(handleAudioSessionInterruption), + name: AVAudioSession.interruptionNotification, object: nil) + #endif + } + + private func setupAudioSession() { + #if !os(tvOS) + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .moviePlayback, options: []) + try audioSession.setActive(true) + print("Audio session configured for media controls") + } catch { + print("Failed to setup audio session: \(error)") + } + #endif + } + + private func setupRemoteCommandCenter() { + #if !os(tvOS) + let commandCenter = MPRemoteCommandCenter.shared() + + // Play command + commandCenter.playCommand.isEnabled = true + commandCenter.playCommand.addTarget { [weak self] _ in + self?.play() + return .success + } + + // Pause command + commandCenter.pauseCommand.isEnabled = true + commandCenter.pauseCommand.addTarget { [weak self] _ in + self?.pause() + return .success + } + + // Toggle play/pause command + commandCenter.togglePlayPauseCommand.isEnabled = true + commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in + guard let self = self, let player = self.mediaPlayer else { + return .commandFailed + } + + if player.isPlaying { + self.pause() + } else { + self.play() + } + return .success + } + + // Seek forward command + commandCenter.skipForwardCommand.isEnabled = true + commandCenter.skipForwardCommand.preferredIntervals = [15] + commandCenter.skipForwardCommand.addTarget { [weak self] event in + guard let self = self, let player = self.mediaPlayer else { + return .commandFailed + } + + let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15 + let currentTime = player.time.intValue + self.seekTo(currentTime + Int32(skipInterval * 1000)) + return .success + } + + // Seek backward command + commandCenter.skipBackwardCommand.isEnabled = true + commandCenter.skipBackwardCommand.preferredIntervals = [15] + commandCenter.skipBackwardCommand.addTarget { [weak self] event in + guard let self = self, let player = self.mediaPlayer else { + return .commandFailed + } + + let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15 + let currentTime = player.time.intValue + self.seekTo(max(0, currentTime - Int32(skipInterval * 1000))) + return .success + } + + // Change playback position command (scrubbing) + commandCenter.changePlaybackPositionCommand.isEnabled = true + commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in + guard let self = self, + let event = event as? MPChangePlaybackPositionCommandEvent else { + return .commandFailed + } + + let positionTime = event.positionTime + self.seekTo(Int32(positionTime * 1000)) + return .success + } + + print("Remote command center configured") + #endif + } + + private func cleanupRemoteCommandCenter() { + #if !os(tvOS) + let commandCenter = MPRemoteCommandCenter.shared() + + // Remove all command targets to prevent memory leaks + commandCenter.playCommand.removeTarget(nil) + commandCenter.pauseCommand.removeTarget(nil) + commandCenter.togglePlayPauseCommand.removeTarget(nil) + commandCenter.skipForwardCommand.removeTarget(nil) + commandCenter.skipBackwardCommand.removeTarget(nil) + commandCenter.changePlaybackPositionCommand.removeTarget(nil) + + // Disable commands + commandCenter.playCommand.isEnabled = false + commandCenter.pauseCommand.isEnabled = false + commandCenter.togglePlayPauseCommand.isEnabled = false + commandCenter.skipForwardCommand.isEnabled = false + commandCenter.skipBackwardCommand.isEnabled = false + commandCenter.changePlaybackPositionCommand.isEnabled = false + + print("Remote command center cleaned up") + #endif } // MARK: - Public Methods func startPictureInPicture() {} @objc func play() { - self.mediaPlayer?.play() - self.isPaused = false - print("Play") + DispatchQueue.main.async { + self.mediaPlayer?.play() + self.isPaused = false + self.updateNowPlayingInfo() + print("Play") + } } @objc func pause() { - self.mediaPlayer?.pause() - self.isPaused = true + DispatchQueue.main.async { + self.mediaPlayer?.pause() + self.isPaused = true + self.updateNowPlayingInfo() + } + } + + @objc func handleAudioSessionInterruption(_ notification: Notification) { + #if !os(tvOS) + guard let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { + return + } + + switch type { + case .began: + // Interruption began - pause the video + print("Audio session interrupted - pausing video") + self.pause() + + case .ended: + // Interruption ended - check if we should resume + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + if options.contains(.shouldResume) { + print("Audio session interruption ended - can resume") + // Don't auto-resume - let user manually resume playback + } else { + print("Audio session interruption ended - should not resume") + } + } + + @unknown default: + break + } + #endif } @objc func seekTo(_ time: Int32) { - guard let player = self.mediaPlayer else { return } + DispatchQueue.main.async { + guard let player = self.mediaPlayer else { return } - let wasPlaying = player.isPlaying - if wasPlaying { - self.pause() - } - - if let duration = player.media?.length.intValue { - print("Seeking to time: \(time) Video Duration \(duration)") - - // If the specified time is greater than the duration, seek to the end - let seekTime = time > duration ? duration - 1000 : time - player.time = VLCTime(int: seekTime) + let wasPlaying = player.isPlaying if wasPlaying { - self.play() + player.pause() + } + + if let duration = player.media?.length.intValue { + print("Seeking to time: \(time) Video Duration \(duration)") + + // If the specified time is greater than the duration, seek to the end + let seekTime = time > duration ? duration - 1000 : time + player.time = VLCTime(int: seekTime) + if wasPlaying { + player.play() + } + self.updatePlayerState() + self.updateNowPlayingInfo() + } else { + print("Error: Unable to retrieve video duration") } - self.updatePlayerState() - } else { - print("Error: Unable to retrieve video duration") } } @@ -263,6 +433,55 @@ class VlcPlayerView: ExpoView { } } + @objc func setNowPlayingMetadata(_ metadata: [String: String]) { + // Cancel any existing artwork download to prevent race conditions + artworkDownloadTask?.cancel() + artworkDownloadTask = nil + + self.nowPlayingMetadata = metadata + print("[NowPlaying] Metadata received: \(metadata)") + + // Load artwork asynchronously if provided + if let artworkUri = metadata["artworkUri"], let url = URL(string: artworkUri) { + print("[NowPlaying] Loading artwork from: \(artworkUri)") + artworkDownloadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in + guard let self = self else { return } + + if let error = error as NSError?, error.code == NSURLErrorCancelled { + print("[NowPlaying] Artwork download cancelled") + return + } + + if let error = error { + print("[NowPlaying] Artwork loading error: \(error)") + DispatchQueue.main.async { + self.updateNowPlayingInfo() + } + } else if let data = data, let image = UIImage(data: data) { + print("[NowPlaying] Artwork loaded successfully, size: \(image.size)") + self.artworkImage = image + DispatchQueue.main.async { + self.updateNowPlayingInfo() + } + } else { + print("[NowPlaying] Failed to create image from data") + // Update Now Playing info without artwork on failure + DispatchQueue.main.async { + self.updateNowPlayingInfo() + } + } + } + artworkDownloadTask?.resume() + } else { + // No artwork URI provided - update immediately + print("[NowPlaying] No artwork URI provided") + artworkImage = nil + DispatchQueue.main.async { + self.updateNowPlayingInfo() + } + } + } + @objc func stop(completion: (() -> Void)? = nil) { guard !isStopping else { completion?() @@ -294,6 +513,27 @@ class VlcPlayerView: ExpoView { // Stop the media player mediaPlayer?.stop() + // Cancel any in-flight artwork downloads + artworkDownloadTask?.cancel() + artworkDownloadTask = nil + artworkImage = nil + + // Cleanup remote command center targets + cleanupRemoteCommandCenter() + + #if !os(tvOS) + // Deactivate audio session to allow other apps to use audio + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + print("Audio session deactivated") + } catch { + print("Failed to deactivate audio session: \(error)") + } + + // Clear Now Playing info + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + #endif + // Remove observer NotificationCenter.default.removeObserver(self) @@ -327,6 +567,60 @@ class VlcPlayerView: ExpoView { "duration": durationMs, ]) } + + // Update Now Playing info to sync elapsed playback time + // iOS needs periodic updates to keep progress indicator in sync + DispatchQueue.main.async { + self.updateNowPlayingInfo() + } + } + + private func updateNowPlayingInfo() { + #if !os(tvOS) + guard let player = self.mediaPlayer else { return } + + var nowPlayingInfo = [String: Any]() + + // Playback rate (0.0 = paused, 1.0 = playing at normal speed) + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? player.rate : 0.0 + + // Current playback time in seconds + let currentTimeSeconds = Double(player.time.intValue) / 1000.0 + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTimeSeconds + + // Total duration in seconds + if let duration = player.media?.length.intValue { + let durationSeconds = Double(duration) / 1000.0 + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = durationSeconds + } + + // Add metadata if available + if let metadata = self.nowPlayingMetadata { + if let title = metadata["title"] { + nowPlayingInfo[MPMediaItemPropertyTitle] = title + print("[NowPlaying] Setting title: \(title)") + } + if let artist = metadata["artist"] { + nowPlayingInfo[MPMediaItemPropertyArtist] = artist + print("[NowPlaying] Setting artist: \(artist)") + } + if let albumTitle = metadata["albumTitle"] { + nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = albumTitle + print("[NowPlaying] Setting album: \(albumTitle)") + } + } + + // Add artwork if available + if let artwork = self.artworkImage { + print("[NowPlaying] Setting artwork with size: \(artwork.size)") + let artworkItem = MPMediaItemArtwork(boundsSize: artwork.size) { _ in + return artwork + } + nowPlayingInfo[MPMediaItemPropertyArtwork] = artworkItem + } + + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + #endif } // MARK: - Expo Events diff --git a/react-native.config.js b/react-native.config.js index 771214a6..6e8801ee 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -13,30 +13,41 @@ const disableForTV = (_moduleName) => } : undefined; -module.exports = { - dependencies: { - "react-native-volume-manager": !isTV - ? { - platforms: { - // leaving this blank seems to enable auto-linking which is what we want for mobile - }, - } - : { - platforms: { - android: null, - }, +const dependencies = { + "react-native-volume-manager": !isTV + ? { + platforms: { + // leaving this blank seems to enable auto-linking which is what we want for mobile }, - "expo-notifications": disableForTV("expo-notifications"), - "react-native-image-colors": disableForTV("react-native-image-colors"), - "expo-sharing": disableForTV("expo-sharing"), - "expo-haptics": disableForTV("expo-haptics"), - "expo-brightness": disableForTV("expo-brightness"), - "expo-sensors": disableForTV("expo-sensors"), - "expo-screen-orientation": disableForTV("expo-screen-orientation"), - "react-native-ios-context-menu": disableForTV( - "react-native-ios-context-menu", - ), - "react-native-ios-utilities": disableForTV("react-native-ios-utilities"), - "react-native-pager-view": disableForTV("react-native-pager-view"), + } + : { + platforms: { + android: null, + }, + }, + "expo-notifications": disableForTV("expo-notifications"), + "react-native-image-colors": disableForTV("react-native-image-colors"), + "expo-sharing": disableForTV("expo-sharing"), + "expo-haptics": disableForTV("expo-haptics"), + "expo-brightness": disableForTV("expo-brightness"), + "expo-sensors": disableForTV("expo-sensors"), + "expo-screen-orientation": disableForTV("expo-screen-orientation"), + "react-native-ios-context-menu": disableForTV( + "react-native-ios-context-menu", + ), + "react-native-ios-utilities": disableForTV("react-native-ios-utilities"), + "react-native-pager-view": disableForTV("react-native-pager-view"), +}; + +// Filter out undefined values +const cleanDependencies = Object.fromEntries( + Object.entries(dependencies).filter(([_, value]) => value !== undefined), +); + +module.exports = { + dependencies: cleanDependencies, + project: { + ios: {}, + android: {}, }, };