mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-27 21:48:12 +00:00
Compare commits
1 Commits
renovate/g
...
feature/na
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
452351a8ef |
@@ -48,6 +48,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
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 {
|
import {
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
@@ -504,6 +505,31 @@ export default function page() {
|
|||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
return ticksToSeconds(getInitialPlaybackTicks());
|
||||||
}, [getInitialPlaybackTicks]);
|
}, [getInitialPlaybackTicks]);
|
||||||
|
|
||||||
|
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
|
||||||
|
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]);
|
||||||
|
|
||||||
/** Build video source config for MPV */
|
/** Build video source config for MPV */
|
||||||
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
||||||
if (!stream?.url) return undefined;
|
if (!stream?.url) return undefined;
|
||||||
@@ -932,6 +958,7 @@ export default function page() {
|
|||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onPlaybackStateChange={onPlaybackStateChanged}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
onLoad={() => setIsVideoLoaded(true)}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ class MpvPlayerModule : Module() {
|
|||||||
view.loadVideo(config)
|
view.loadVideo(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now Playing metadata for media controls (iOS-only, no-op on Android)
|
||||||
|
// Android handles media session differently via MediaSessionCompat
|
||||||
|
Prop("nowPlayingMetadata") { _: MpvPlayerView, _: Map<String, String>? ->
|
||||||
|
// No-op on Android - media session integration would require MediaSessionCompat
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to play video
|
// Async function to play video
|
||||||
AsyncFunction("play") { view: MpvPlayerView ->
|
AsyncFunction("play") { view: MpvPlayerView ->
|
||||||
view.play()
|
view.play()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ protocol MPVLayerRendererDelegate: AnyObject {
|
|||||||
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
||||||
|
func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MPV player using vo_avfoundation for video output.
|
/// MPV player using vo_avfoundation for video output.
|
||||||
@@ -347,7 +348,8 @@ final class MPVLayerRenderer {
|
|||||||
("pause", MPV_FORMAT_FLAG),
|
("pause", MPV_FORMAT_FLAG),
|
||||||
("track-list/count", MPV_FORMAT_INT64),
|
("track-list/count", MPV_FORMAT_INT64),
|
||||||
("paused-for-cache", MPV_FORMAT_FLAG),
|
("paused-for-cache", MPV_FORMAT_FLAG),
|
||||||
("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
("demuxer-cache-duration", MPV_FORMAT_DOUBLE),
|
||||||
|
("current-ao", MPV_FORMAT_STRING)
|
||||||
]
|
]
|
||||||
for (name, format) in properties {
|
for (name, format) in properties {
|
||||||
mpv_observe_property(handle, 0, name, format)
|
mpv_observe_property(handle, 0, name, format)
|
||||||
@@ -552,6 +554,15 @@ final class MPVLayerRenderer {
|
|||||||
self.delegate?.renderer(self, didBecomeTracksReady: true)
|
self.delegate?.renderer(self, didBecomeTracksReady: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "current-ao":
|
||||||
|
// Audio output is now active - notify delegate
|
||||||
|
if let aoName = getStringProperty(handle: handle, name: name) {
|
||||||
|
print("[MPV] 🔊 Audio output selected: \(aoName)")
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didSelectAudioOutput: aoName)
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
188
modules/mpv-player/ios/MPVNowPlayingManager.swift
Normal file
188
modules/mpv-player/ios/MPVNowPlayingManager.swift
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaPlayer
|
||||||
|
import UIKit
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// Simple manager for Now Playing info and remote commands.
|
||||||
|
/// Stores all state internally and updates Now Playing when ready.
|
||||||
|
class MPVNowPlayingManager {
|
||||||
|
static let shared = MPVNowPlayingManager()
|
||||||
|
|
||||||
|
// State
|
||||||
|
private var title: String?
|
||||||
|
private var artist: String?
|
||||||
|
private var albumTitle: String?
|
||||||
|
private var cachedArtwork: MPMediaItemArtwork?
|
||||||
|
private var duration: TimeInterval = 0
|
||||||
|
private var position: TimeInterval = 0
|
||||||
|
private var isPlaying: Bool = false
|
||||||
|
private var isCommandsSetup = false
|
||||||
|
|
||||||
|
private var artworkTask: URLSessionDataTask?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Audio Session
|
||||||
|
|
||||||
|
func activateAudioSession() {
|
||||||
|
do {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(.playback, mode: .moviePlayback)
|
||||||
|
try session.setActive(true)
|
||||||
|
print("[NowPlaying] Audio session activated")
|
||||||
|
} catch {
|
||||||
|
print("[NowPlaying] Audio session error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivateAudioSession() {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
print("[NowPlaying] Audio session deactivated")
|
||||||
|
} catch {
|
||||||
|
print("[NowPlaying] Deactivation error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote Commands
|
||||||
|
|
||||||
|
func setupRemoteCommands(
|
||||||
|
playHandler: @escaping () -> Void,
|
||||||
|
pauseHandler: @escaping () -> Void,
|
||||||
|
toggleHandler: @escaping () -> Void,
|
||||||
|
seekHandler: @escaping (TimeInterval) -> Void,
|
||||||
|
skipForward: @escaping (TimeInterval) -> Void,
|
||||||
|
skipBackward: @escaping (TimeInterval) -> Void
|
||||||
|
) {
|
||||||
|
guard !isCommandsSetup else { return }
|
||||||
|
isCommandsSetup = true
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
let cc = MPRemoteCommandCenter.shared()
|
||||||
|
|
||||||
|
cc.playCommand.isEnabled = true
|
||||||
|
cc.playCommand.addTarget { _ in playHandler(); return .success }
|
||||||
|
|
||||||
|
cc.pauseCommand.isEnabled = true
|
||||||
|
cc.pauseCommand.addTarget { _ in pauseHandler(); return .success }
|
||||||
|
|
||||||
|
cc.togglePlayPauseCommand.isEnabled = true
|
||||||
|
cc.togglePlayPauseCommand.addTarget { _ in toggleHandler(); return .success }
|
||||||
|
|
||||||
|
cc.skipForwardCommand.isEnabled = true
|
||||||
|
cc.skipForwardCommand.preferredIntervals = [15]
|
||||||
|
cc.skipForwardCommand.addTarget { e in
|
||||||
|
if let ev = e as? MPSkipIntervalCommandEvent { skipForward(ev.interval) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
cc.skipBackwardCommand.isEnabled = true
|
||||||
|
cc.skipBackwardCommand.preferredIntervals = [15]
|
||||||
|
cc.skipBackwardCommand.addTarget { e in
|
||||||
|
if let ev = e as? MPSkipIntervalCommandEvent { skipBackward(ev.interval) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
cc.changePlaybackPositionCommand.isEnabled = true
|
||||||
|
cc.changePlaybackPositionCommand.addTarget { e in
|
||||||
|
if let ev = e as? MPChangePlaybackPositionCommandEvent { seekHandler(ev.positionTime) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[NowPlaying] Remote commands ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupRemoteCommands() {
|
||||||
|
guard isCommandsSetup else { return }
|
||||||
|
|
||||||
|
let cc = MPRemoteCommandCenter.shared()
|
||||||
|
cc.playCommand.removeTarget(nil)
|
||||||
|
cc.pauseCommand.removeTarget(nil)
|
||||||
|
cc.togglePlayPauseCommand.removeTarget(nil)
|
||||||
|
cc.skipForwardCommand.removeTarget(nil)
|
||||||
|
cc.skipBackwardCommand.removeTarget(nil)
|
||||||
|
cc.changePlaybackPositionCommand.removeTarget(nil)
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
UIApplication.shared.endReceivingRemoteControlEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
isCommandsSetup = false
|
||||||
|
print("[NowPlaying] Remote commands cleaned up")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State Updates (call these whenever data changes)
|
||||||
|
|
||||||
|
/// Set metadata (title, artist, artwork URL)
|
||||||
|
func setMetadata(title: String?, artist: String?, albumTitle: String?, artworkUrl: String?) {
|
||||||
|
self.title = title
|
||||||
|
self.artist = artist
|
||||||
|
self.albumTitle = albumTitle
|
||||||
|
|
||||||
|
print("[NowPlaying] Metadata: \(title ?? "nil")")
|
||||||
|
|
||||||
|
// Load artwork async
|
||||||
|
artworkTask?.cancel()
|
||||||
|
if let urlString = artworkUrl, let url = URL(string: urlString) {
|
||||||
|
artworkTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||||
|
if let data = data, let image = UIImage(data: data) {
|
||||||
|
self?.cachedArtwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||||
|
print("[NowPlaying] Artwork loaded")
|
||||||
|
DispatchQueue.main.async { self?.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artworkTask?.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update playback state (position, duration, playing)
|
||||||
|
func updatePlayback(position: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
|
||||||
|
self.position = position
|
||||||
|
self.duration = duration
|
||||||
|
self.isPlaying = isPlaying
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear everything
|
||||||
|
func clear() {
|
||||||
|
artworkTask?.cancel()
|
||||||
|
title = nil
|
||||||
|
artist = nil
|
||||||
|
albumTitle = nil
|
||||||
|
cachedArtwork = nil
|
||||||
|
duration = 0
|
||||||
|
position = 0
|
||||||
|
isPlaying = false
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||||
|
print("[NowPlaying] Cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
/// Refresh Now Playing info if we have enough data
|
||||||
|
private func refresh() {
|
||||||
|
guard duration > 0 else {
|
||||||
|
print("[NowPlaying] refresh skipped - duration is 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var info: [String: Any] = [
|
||||||
|
MPMediaItemPropertyPlaybackDuration: duration,
|
||||||
|
MPNowPlayingInfoPropertyElapsedPlaybackTime: position,
|
||||||
|
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0
|
||||||
|
]
|
||||||
|
|
||||||
|
if let title { info[MPMediaItemPropertyTitle] = title }
|
||||||
|
if let artist { info[MPMediaItemPropertyArtist] = artist }
|
||||||
|
if let albumTitle { info[MPMediaItemPropertyAlbumTitle] = albumTitle }
|
||||||
|
if let cachedArtwork { info[MPMediaItemPropertyArtwork] = cachedArtwork }
|
||||||
|
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||||
|
print("[NowPlaying] ✅ Set info: title=\(title ?? "nil"), dur=\(Int(duration))s, pos=\(Int(position))s, rate=\(isPlaying ? 1.0 : 0.0)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,21 @@ public class MpvPlayerModule: Module {
|
|||||||
view.loadVideo(config: config)
|
view.loadVideo(config: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now Playing metadata for iOS Control Center and Lock Screen
|
||||||
|
Prop("nowPlayingMetadata") { (view: MpvPlayerView, metadata: [String: Any]?) in
|
||||||
|
guard let metadata = metadata else { return }
|
||||||
|
// Convert Any values to String, filtering out nil/null values
|
||||||
|
var stringMetadata: [String: String] = [:]
|
||||||
|
for (key, value) in metadata {
|
||||||
|
if let stringValue = value as? String {
|
||||||
|
stringMetadata[key] = stringValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !stringMetadata.isEmpty {
|
||||||
|
view.setNowPlayingMetadata(stringMetadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to play video
|
// Async function to play video
|
||||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||||
view.play()
|
view.play()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import CoreMedia
|
import CoreMedia
|
||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
|
import MediaPlayer
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// Configuration for loading a video
|
/// Configuration for loading a video
|
||||||
@@ -41,7 +42,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
private var renderer: MPVLayerRenderer?
|
private var renderer: MPVLayerRenderer?
|
||||||
private var videoContainer: UIView!
|
private var videoContainer: UIView!
|
||||||
private var pipController: PiPController?
|
private var pipController: PiPController?
|
||||||
|
|
||||||
let onLoad = EventDispatcher()
|
let onLoad = EventDispatcher()
|
||||||
let onPlaybackStateChange = EventDispatcher()
|
let onPlaybackStateChange = EventDispatcher()
|
||||||
let onProgress = EventDispatcher()
|
let onProgress = EventDispatcher()
|
||||||
@@ -53,11 +53,14 @@ class MpvPlayerView: ExpoView {
|
|||||||
private var cachedDuration: Double = 0
|
private var cachedDuration: Double = 0
|
||||||
private var intendedPlayState: Bool = false
|
private var intendedPlayState: Bool = false
|
||||||
private var _isZoomedToFill: Bool = false
|
private var _isZoomedToFill: Bool = false
|
||||||
|
|
||||||
|
// Reference to now playing manager
|
||||||
|
private let nowPlayingManager = MPVNowPlayingManager.shared
|
||||||
|
|
||||||
required init(appContext: AppContext? = nil) {
|
required init(appContext: AppContext? = nil) {
|
||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
|
setupNotifications()
|
||||||
setupView()
|
setupView()
|
||||||
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupView() {
|
private func setupView() {
|
||||||
@@ -109,6 +112,77 @@ class MpvPlayerView: ExpoView {
|
|||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Session & Notifications
|
||||||
|
|
||||||
|
private func setupNotifications() {
|
||||||
|
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleAudioSessionInterruption),
|
||||||
|
name: AVAudioSession.interruptionNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
||||||
|
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("[MPV] 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("[MPV] Audio session interruption ended - can resume")
|
||||||
|
// Don't auto-resume - let user manually resume playback
|
||||||
|
} else {
|
||||||
|
print("[MPV] Audio session interruption ended - should not resume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupRemoteCommands() {
|
||||||
|
nowPlayingManager.setupRemoteCommands(
|
||||||
|
playHandler: { [weak self] in self?.play() },
|
||||||
|
pauseHandler: { [weak self] in self?.pause() },
|
||||||
|
toggleHandler: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if self.intendedPlayState { self.pause() } else { self.play() }
|
||||||
|
},
|
||||||
|
seekHandler: { [weak self] time in self?.seekTo(position: time) },
|
||||||
|
skipForward: { [weak self] interval in self?.seekBy(offset: interval) },
|
||||||
|
skipBackward: { [weak self] interval in self?.seekBy(offset: -interval) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Now Playing Info
|
||||||
|
|
||||||
|
func setNowPlayingMetadata(_ metadata: [String: String]) {
|
||||||
|
print("[MPV] setNowPlayingMetadata: \(metadata["title"] ?? "nil")")
|
||||||
|
nowPlayingManager.setMetadata(
|
||||||
|
title: metadata["title"],
|
||||||
|
artist: metadata["artist"],
|
||||||
|
albumTitle: metadata["albumTitle"],
|
||||||
|
artworkUrl: metadata["artworkUri"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearNowPlayingInfo() {
|
||||||
|
nowPlayingManager.cleanupRemoteCommands()
|
||||||
|
nowPlayingManager.deactivateAudioSession()
|
||||||
|
nowPlayingManager.clear()
|
||||||
|
}
|
||||||
|
|
||||||
func loadVideo(config: VideoLoadConfig) {
|
func loadVideo(config: VideoLoadConfig) {
|
||||||
// Skip reload if same URL is already playing
|
// Skip reload if same URL is already playing
|
||||||
if currentURL == config.url {
|
if currentURL == config.url {
|
||||||
@@ -149,6 +223,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
|
setupRemoteCommands()
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
pipController?.updatePlaybackState()
|
pipController?.updatePlaybackState()
|
||||||
@@ -162,10 +237,17 @@ class MpvPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func seekTo(position: Double) {
|
func seekTo(position: Double) {
|
||||||
|
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||||
|
cachedPosition = position
|
||||||
|
syncNowPlaying(isPlaying: !isPaused())
|
||||||
renderer?.seek(to: position)
|
renderer?.seek(to: position)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seekBy(offset: Double) {
|
func seekBy(offset: Double) {
|
||||||
|
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||||
|
let newPosition = max(0, min(cachedPosition + offset, cachedDuration))
|
||||||
|
cachedPosition = newPosition
|
||||||
|
syncNowPlaying(isPlaying: !isPaused())
|
||||||
renderer?.seek(by: offset)
|
renderer?.seek(by: offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,23 +374,32 @@ class MpvPlayerView: ExpoView {
|
|||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
displayLayer.removeFromSuperlayer()
|
displayLayer.removeFromSuperlayer()
|
||||||
|
clearNowPlayingInfo()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - MPVLayerRendererDelegate
|
// MARK: - MPVLayerRendererDelegate
|
||||||
|
|
||||||
extension MpvPlayerView: MPVLayerRendererDelegate {
|
extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||||
|
|
||||||
|
// MARK: - Single location for Now Playing updates
|
||||||
|
private func syncNowPlaying(isPlaying: Bool) {
|
||||||
|
print("[MPV] syncNowPlaying: pos=\(Int(cachedPosition))s, dur=\(Int(cachedDuration))s, playing=\(isPlaying)")
|
||||||
|
nowPlayingManager.updatePlayback(position: cachedPosition, duration: cachedDuration, isPlaying: isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
|
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
|
||||||
cachedPosition = position
|
cachedPosition = position
|
||||||
cachedDuration = duration
|
cachedDuration = duration
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// Update PiP current time for progress bar
|
|
||||||
if self.pipController?.isPictureInPictureActive == true {
|
if self.pipController?.isPictureInPictureActive == true {
|
||||||
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
|
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.onProgress([
|
self.onProgress([
|
||||||
"position": position,
|
"position": position,
|
||||||
"duration": duration,
|
"duration": duration,
|
||||||
@@ -321,12 +412,10 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
|
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// Don't update intendedPlayState here - it's only set by user actions (play/pause)
|
|
||||||
// This prevents PiP UI flicker during seeking
|
|
||||||
|
|
||||||
// Sync timebase rate with actual playback state
|
print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)")
|
||||||
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
|
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
|
||||||
|
self.syncNowPlaying(isPlaying: !isPaused)
|
||||||
self.onPlaybackStateChange([
|
self.onPlaybackStateChange([
|
||||||
"isPaused": isPaused,
|
"isPaused": isPaused,
|
||||||
"isPlaying": !isPaused,
|
"isPlaying": !isPaused,
|
||||||
@@ -358,6 +447,13 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
self.onTracksReady([:])
|
self.onTracksReady([:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||||
|
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
||||||
|
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
||||||
|
nowPlayingManager.activateAudioSession()
|
||||||
|
syncNowPlaying(isPlaying: !isPaused())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PiPControllerDelegate
|
// MARK: - PiPControllerDelegate
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export type OnErrorEventPayload = {
|
|||||||
|
|
||||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||||
|
|
||||||
|
export type NowPlayingMetadata = {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
albumTitle?: string;
|
||||||
|
artworkUri?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MpvPlayerModuleEvents = {
|
export type MpvPlayerModuleEvents = {
|
||||||
onChange: (params: ChangeEventPayload) => void;
|
onChange: (params: ChangeEventPayload) => void;
|
||||||
};
|
};
|
||||||
@@ -48,6 +55,8 @@ export type VideoSource = {
|
|||||||
export type MpvPlayerViewProps = {
|
export type MpvPlayerViewProps = {
|
||||||
source?: VideoSource;
|
source?: VideoSource;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
|
/** Metadata for iOS Control Center and Lock Screen now playing info */
|
||||||
|
nowPlayingMetadata?: NowPlayingMetadata;
|
||||||
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
|
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
|
||||||
onPlaybackStateChange?: (event: {
|
onPlaybackStateChange?: (event: {
|
||||||
nativeEvent: OnPlaybackStateChangePayload;
|
nativeEvent: OnPlaybackStateChangePayload;
|
||||||
|
|||||||
Reference in New Issue
Block a user