mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: vlc apple integration - pause on other media play + controls (#1211)
This commit is contained in:
committed by
GitHub
parent
94362169b6
commit
5f48bec0f2
@@ -43,6 +43,7 @@ import { useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
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 { generateDeviceProfile } from "@/utils/profiles/native";
|
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
|
// Show error UI first, before checking loading/missing‐data
|
||||||
if (itemStatus.isError || streamStatus.isError) {
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
@@ -731,6 +755,7 @@ export default function page() {
|
|||||||
initOptions,
|
initOptions,
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onVideoProgress={onProgress}
|
onVideoProgress={onProgress}
|
||||||
progressUpdateInterval={1000}
|
progressUpdateInterval={1000}
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
onVideoStateChange={onPlaybackStateChanged}
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ const SkipButton: React.FC<SkipButtonProps> = ({
|
|||||||
buttonText,
|
buttonText,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
console.log(`[SKIP_BUTTON] Render:`, {
|
|
||||||
buttonText,
|
|
||||||
showButton,
|
|
||||||
className: showButton ? "flex" : "hidden",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={showButton ? "flex" : "hidden"} {...props}>
|
<View className={showButton ? "flex" : "hidden"} {...props}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -130,7 +130,13 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
if (getSubtitleTracks) {
|
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.
|
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
|
||||||
if (
|
if (
|
||||||
mediaSource?.TranscodingUrl &&
|
mediaSource?.TranscodingUrl &&
|
||||||
@@ -179,7 +185,13 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
setSubtitleTracks(subtitles);
|
setSubtitleTracks(subtitles);
|
||||||
}
|
}
|
||||||
if (getAudioTracks) {
|
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 =
|
const allAudio =
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||||
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
if (playerRef.current) {
|
if (playerRef.current) {
|
||||||
const audio = await playerRef.current.getAudioTracks();
|
try {
|
||||||
const subtitles = await playerRef.current.getSubtitleTracks();
|
const audio = await playerRef.current.getAudioTracks();
|
||||||
setAudioTracks(audio);
|
const subtitles = await playerRef.current.getSubtitleTracks();
|
||||||
setSubtitleTracks(subtitles);
|
setAudioTracks(audio);
|
||||||
|
setSubtitleTracks(subtitles);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[VideoDebugInfo] Failed to fetch tracks:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,8 +64,24 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
|
|||||||
className='mt-2.5 bg-blue-500 p-2 rounded'
|
className='mt-2.5 bg-blue-500 p-2 rounded'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (playerRef.current) {
|
if (playerRef.current) {
|
||||||
playerRef.current.getAudioTracks().then(setAudioTracks);
|
playerRef.current
|
||||||
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
|
.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,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -43,37 +43,14 @@ export const useIntroSkipper = (
|
|||||||
const introTimestamps = segments?.introSegments?.[0];
|
const introTimestamps = segments?.introSegments?.[0];
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if (introTimestamps) {
|
||||||
const shouldShow =
|
const shouldShow =
|
||||||
currentTime > introTimestamps.startTime &&
|
currentTime > introTimestamps.startTime &&
|
||||||
currentTime < introTimestamps.endTime;
|
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);
|
setShowSkipButton(shouldShow);
|
||||||
} else {
|
} else {
|
||||||
if (showSkipButton) {
|
if (showSkipButton) {
|
||||||
console.log(`[INTRO_SKIPPER] No intro timestamps, hiding button`);
|
|
||||||
setShowSkipButton(false);
|
setShowSkipButton(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,10 +59,6 @@ export const useIntroSkipper = (
|
|||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
console.log(
|
|
||||||
`[INTRO_SKIPPER] Skipping intro to:`,
|
|
||||||
introTimestamps.endTime,
|
|
||||||
);
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
wrappedSeek(introTimestamps.endTime);
|
wrappedSeek(introTimestamps.endTime);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -96,7 +69,5 @@ export const useIntroSkipper = (
|
|||||||
}
|
}
|
||||||
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||||
|
|
||||||
console.log(`[INTRO_SKIPPER] Returning state:`, { showSkipButton });
|
|
||||||
|
|
||||||
return { showSkipButton, skipIntro };
|
return { showSkipButton, skipIntro };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ export type ChapterInfo = {
|
|||||||
duration: number;
|
duration: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NowPlayingMetadata = {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
albumTitle?: string;
|
||||||
|
artworkUri?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type VlcPlayerViewProps = {
|
export type VlcPlayerViewProps = {
|
||||||
source: VlcPlayerSource;
|
source: VlcPlayerSource;
|
||||||
style?: ViewStyle | ViewStyle[];
|
style?: ViewStyle | ViewStyle[];
|
||||||
@@ -67,6 +74,7 @@ export type VlcPlayerViewProps = {
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
videoAspectRatio?: string;
|
videoAspectRatio?: string;
|
||||||
|
nowPlayingMetadata?: NowPlayingMetadata;
|
||||||
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
||||||
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
||||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
muted,
|
muted,
|
||||||
volume,
|
volume,
|
||||||
videoAspectRatio,
|
videoAspectRatio,
|
||||||
|
nowPlayingMetadata,
|
||||||
onVideoLoadStart,
|
onVideoLoadStart,
|
||||||
onVideoStateChange,
|
onVideoStateChange,
|
||||||
onVideoProgress,
|
onVideoProgress,
|
||||||
@@ -131,6 +132,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
muted={muted}
|
muted={muted}
|
||||||
volume={volume}
|
volume={volume}
|
||||||
videoAspectRatio={videoAspectRatio}
|
videoAspectRatio={videoAspectRatio}
|
||||||
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onVideoLoadStart={onVideoLoadStart}
|
onVideoLoadStart={onVideoLoadStart}
|
||||||
onVideoLoadEnd={onVideoLoadEnd}
|
onVideoLoadEnd={onVideoLoadEnd}
|
||||||
onVideoStateChange={onVideoStateChange}
|
onVideoStateChange={onVideoStateChange}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public class VlcPlayerModule: Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Prop("nowPlayingMetadata") { (view: VlcPlayerView, metadata: [String: String]?) in
|
||||||
|
if let metadata = metadata {
|
||||||
|
view.setNowPlayingMetadata(metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Events(
|
Events(
|
||||||
"onPlaybackStateChanged",
|
"onPlaybackStateChanged",
|
||||||
"onVideoStateChange",
|
"onVideoStateChange",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
|
import MediaPlayer
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
import TVVLCKit
|
import TVVLCKit
|
||||||
@@ -24,6 +26,9 @@ class VlcPlayerView: ExpoView {
|
|||||||
var hasSource = false
|
var hasSource = false
|
||||||
var isTranscoding = false
|
var isTranscoding = false
|
||||||
private var initialSeekPerformed: Bool = false
|
private var initialSeekPerformed: Bool = false
|
||||||
|
private var nowPlayingMetadata: [String: String]?
|
||||||
|
private var artworkImage: UIImage?
|
||||||
|
private var artworkDownloadTask: URLSessionDataTask?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
@@ -31,6 +36,8 @@ class VlcPlayerView: ExpoView {
|
|||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
setupView()
|
setupView()
|
||||||
setupNotifications()
|
setupNotifications()
|
||||||
|
setupRemoteCommandCenter()
|
||||||
|
setupAudioSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
@@ -60,42 +67,205 @@ class VlcPlayerView: ExpoView {
|
|||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(applicationDidBecomeActive),
|
self, selector: #selector(applicationDidBecomeActive),
|
||||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
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
|
// MARK: - Public Methods
|
||||||
func startPictureInPicture() {}
|
func startPictureInPicture() {}
|
||||||
|
|
||||||
@objc func play() {
|
@objc func play() {
|
||||||
self.mediaPlayer?.play()
|
DispatchQueue.main.async {
|
||||||
self.isPaused = false
|
self.mediaPlayer?.play()
|
||||||
print("Play")
|
self.isPaused = false
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
print("Play")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func pause() {
|
@objc func pause() {
|
||||||
self.mediaPlayer?.pause()
|
DispatchQueue.main.async {
|
||||||
self.isPaused = true
|
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) {
|
@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
|
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)
|
|
||||||
if wasPlaying {
|
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) {
|
@objc func stop(completion: (() -> Void)? = nil) {
|
||||||
guard !isStopping else {
|
guard !isStopping else {
|
||||||
completion?()
|
completion?()
|
||||||
@@ -294,6 +513,27 @@ class VlcPlayerView: ExpoView {
|
|||||||
// Stop the media player
|
// Stop the media player
|
||||||
mediaPlayer?.stop()
|
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
|
// Remove observer
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
|
||||||
@@ -327,6 +567,60 @@ class VlcPlayerView: ExpoView {
|
|||||||
"duration": durationMs,
|
"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
|
// MARK: - Expo Events
|
||||||
|
|||||||
@@ -13,30 +13,41 @@ const disableForTV = (_moduleName) =>
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
module.exports = {
|
const dependencies = {
|
||||||
dependencies: {
|
"react-native-volume-manager": !isTV
|
||||||
"react-native-volume-manager": !isTV
|
? {
|
||||||
? {
|
platforms: {
|
||||||
platforms: {
|
// leaving this blank seems to enable auto-linking which is what we want for mobile
|
||||||
// leaving this blank seems to enable auto-linking which is what we want for mobile
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
platforms: {
|
|
||||||
android: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"expo-notifications": disableForTV("expo-notifications"),
|
}
|
||||||
"react-native-image-colors": disableForTV("react-native-image-colors"),
|
: {
|
||||||
"expo-sharing": disableForTV("expo-sharing"),
|
platforms: {
|
||||||
"expo-haptics": disableForTV("expo-haptics"),
|
android: null,
|
||||||
"expo-brightness": disableForTV("expo-brightness"),
|
},
|
||||||
"expo-sensors": disableForTV("expo-sensors"),
|
},
|
||||||
"expo-screen-orientation": disableForTV("expo-screen-orientation"),
|
"expo-notifications": disableForTV("expo-notifications"),
|
||||||
"react-native-ios-context-menu": disableForTV(
|
"react-native-image-colors": disableForTV("react-native-image-colors"),
|
||||||
"react-native-ios-context-menu",
|
"expo-sharing": disableForTV("expo-sharing"),
|
||||||
),
|
"expo-haptics": disableForTV("expo-haptics"),
|
||||||
"react-native-ios-utilities": disableForTV("react-native-ios-utilities"),
|
"expo-brightness": disableForTV("expo-brightness"),
|
||||||
"react-native-pager-view": disableForTV("react-native-pager-view"),
|
"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: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user