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
@@ -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;
|
||||
|
||||
@@ -102,6 +102,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
muted,
|
||||
volume,
|
||||
videoAspectRatio,
|
||||
nowPlayingMetadata,
|
||||
onVideoLoadStart,
|
||||
onVideoStateChange,
|
||||
onVideoProgress,
|
||||
@@ -131,6 +132,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
videoAspectRatio={videoAspectRatio}
|
||||
nowPlayingMetadata={nowPlayingMetadata}
|
||||
onVideoLoadStart={onVideoLoadStart}
|
||||
onVideoLoadEnd={onVideoLoadEnd}
|
||||
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(
|
||||
"onPlaybackStateChanged",
|
||||
"onVideoStateChange",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user