mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-27 13:38:28 +00:00
Compare commits
1 Commits
feature/na
...
renovate/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d27bc608d |
@@ -48,7 +48,6 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
getMpvAudioId,
|
||||
@@ -450,7 +449,7 @@ export default function page() {
|
||||
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
|
||||
if (isSeeking.get() || isPlaybackStopped) return;
|
||||
|
||||
const { position, cacheSeconds } = data.nativeEvent;
|
||||
const { position } = data.nativeEvent;
|
||||
// MPV reports position in seconds, convert to ms
|
||||
const currentTime = position * 1000;
|
||||
|
||||
@@ -460,12 +459,6 @@ export default function page() {
|
||||
|
||||
progress.set(currentTime);
|
||||
|
||||
// Update cache progress (current position + buffered seconds ahead)
|
||||
if (cacheSeconds !== undefined && cacheSeconds > 0) {
|
||||
const cacheEnd = currentTime + cacheSeconds * 1000;
|
||||
cacheProgress.set(cacheEnd);
|
||||
}
|
||||
|
||||
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
||||
const now = Date.now();
|
||||
const shouldUpdateUrl = wasJustSeeking.get();
|
||||
@@ -505,31 +498,6 @@ export default function page() {
|
||||
return ticksToSeconds(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 */
|
||||
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
||||
if (!stream?.url) return undefined;
|
||||
@@ -958,7 +926,6 @@ export default function page() {
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
nowPlayingMetadata={nowPlayingMetadata}
|
||||
onProgress={onProgress}
|
||||
onPlaybackStateChange={onPlaybackStateChanged}
|
||||
onLoad={() => setIsVideoLoaded(true)}
|
||||
|
||||
6
bun.lock
6
bun.lock
@@ -76,7 +76,7 @@
|
||||
"react-native-mmkv": "4.1.1",
|
||||
"react-native-nitro-modules": "0.33.1",
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-reanimated": "~4.2.0",
|
||||
"react-native-reanimated-carousel": "4.0.3",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.18.0",
|
||||
@@ -1682,7 +1682,7 @@
|
||||
|
||||
"react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],
|
||||
|
||||
"react-native-reanimated": ["react-native-reanimated@4.1.3", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*", "react-native-worklets": ">=0.5.0" } }, "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg=="],
|
||||
"react-native-reanimated": ["react-native-reanimated@4.2.1", "", { "dependencies": { "react-native-is-edge-to-edge": "1.2.1", "semver": "7.7.3" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": ">=0.7.0" } }, "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg=="],
|
||||
|
||||
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
|
||||
|
||||
@@ -2430,7 +2430,7 @@
|
||||
|
||||
"react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
"react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="],
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,13 +1,10 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.AssetManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* MPV renderer that wraps libmpv for video playback.
|
||||
@@ -29,7 +26,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
interface Delegate {
|
||||
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
|
||||
fun onPositionChanged(position: Double, duration: Double)
|
||||
fun onPauseChanged(isPaused: Boolean)
|
||||
fun onLoadingChanged(isLoading: Boolean)
|
||||
fun onReadyToSeek()
|
||||
@@ -49,7 +46,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
// Cached state
|
||||
private var cachedPosition: Double = 0.0
|
||||
private var cachedDuration: Double = 0.0
|
||||
private var cachedCacheSeconds: Double = 0.0
|
||||
private var _isPaused: Boolean = true
|
||||
private var _isLoading: Boolean = false
|
||||
private var _playbackSpeed: Double = 1.0
|
||||
@@ -105,52 +101,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
MPVLib.create(context)
|
||||
MPVLib.addObserver(this)
|
||||
|
||||
/**
|
||||
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
|
||||
*
|
||||
* Technical Background:
|
||||
* ====================
|
||||
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
|
||||
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
|
||||
* even when subtitle tracks are properly detected and loaded.
|
||||
*
|
||||
* Why This Is Necessary:
|
||||
* =====================
|
||||
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
|
||||
* mpv cannot access them directly due to sandboxing and library isolation.
|
||||
*
|
||||
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
|
||||
* configured directory, mpv either:
|
||||
* - Fails silently (subtitles don't appear)
|
||||
* - Falls back to a default font that may not support the required character set
|
||||
* - Crashes or produces rendering errors
|
||||
*
|
||||
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
|
||||
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
|
||||
*
|
||||
* Reference:
|
||||
* =========
|
||||
* This workaround is documented in the mpv-android project:
|
||||
* https://github.com/mpv-android/mpv-android/issues/96
|
||||
*
|
||||
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
|
||||
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
|
||||
*/
|
||||
// Create mpv config directory and copy font files
|
||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||
//Log.i(TAG, "mpv config dir: $mpvDir")
|
||||
if (!mpvDir.exists()) mpvDir.mkdirs()
|
||||
// This needs to be named `subfont.ttf` else it won't work
|
||||
arrayOf("subfont.ttf").forEach { fileName ->
|
||||
val file = File(mpvDir, fileName)
|
||||
if (file.exists()) return@forEach
|
||||
context.assets
|
||||
.open(fileName, AssetManager.ACCESS_STREAMING)
|
||||
.copyTo(FileOutputStream(file))
|
||||
}
|
||||
MPVLib.setOptionString("config", "yes")
|
||||
MPVLib.setOptionString("config-dir", mpvDir.path)
|
||||
|
||||
// Configure mpv options before initialization (based on Findroid)
|
||||
MPVLib.setOptionString("vo", "gpu")
|
||||
MPVLib.setOptionString("gpu-context", "android")
|
||||
@@ -174,7 +124,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
MPVLib.setOptionString("hr-seek-framedrop", "yes")
|
||||
|
||||
// Subtitle settings
|
||||
MPVLib.setOptionString("sub-scale-with-window", "no")
|
||||
MPVLib.setOptionString("sub-scale-with-window", "yes")
|
||||
MPVLib.setOptionString("sub-use-margins", "no")
|
||||
MPVLib.setOptionString("subs-match-os-language", "yes")
|
||||
MPVLib.setOptionString("subs-fallback", "yes")
|
||||
@@ -333,7 +283,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||
// Video dimensions for PiP aspect ratio
|
||||
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||
@@ -612,7 +561,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
when (property) {
|
||||
"duration" -> {
|
||||
cachedDuration = value
|
||||
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
|
||||
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
|
||||
}
|
||||
"time-pos" -> {
|
||||
cachedPosition = value
|
||||
@@ -621,12 +570,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000)
|
||||
if (shouldUpdate) {
|
||||
lastProgressUpdateTime = now
|
||||
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
|
||||
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
|
||||
}
|
||||
}
|
||||
"demuxer-cache-duration" -> {
|
||||
cachedCacheSeconds = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,12 +43,6 @@ class MpvPlayerModule : Module() {
|
||||
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
|
||||
AsyncFunction("play") { view: MpvPlayerView ->
|
||||
view.play()
|
||||
|
||||
@@ -307,7 +307,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
// MARK: - MPVLayerRenderer.Delegate
|
||||
|
||||
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
|
||||
override fun onPositionChanged(position: Double, duration: Double) {
|
||||
cachedPosition = position
|
||||
cachedDuration = duration
|
||||
|
||||
@@ -319,8 +319,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
onProgress(mapOf(
|
||||
"position" to position,
|
||||
"duration" to duration,
|
||||
"progress" to if (duration > 0) position / duration else 0.0,
|
||||
"cacheSeconds" to cacheSeconds
|
||||
"progress" to if (duration > 0) position / duration else 0.0
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,11 @@ import CoreVideo
|
||||
import AVFoundation
|
||||
|
||||
protocol MPVLayerRendererDelegate: AnyObject {
|
||||
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String)
|
||||
}
|
||||
|
||||
/// MPV player using vo_avfoundation for video output.
|
||||
@@ -45,7 +44,6 @@ final class MPVLayerRenderer {
|
||||
// Thread-safe state for playback
|
||||
private var _cachedDuration: Double = 0
|
||||
private var _cachedPosition: Double = 0
|
||||
private var _cachedCacheSeconds: Double = 0
|
||||
private var _isPaused: Bool = true
|
||||
private var _playbackSpeed: Double = 1.0
|
||||
private var _isLoading: Bool = false
|
||||
@@ -77,10 +75,6 @@ final class MPVLayerRenderer {
|
||||
get { stateQueue.sync { _cachedPosition } }
|
||||
set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } }
|
||||
}
|
||||
private var cachedCacheSeconds: Double {
|
||||
get { stateQueue.sync { _cachedCacheSeconds } }
|
||||
set { stateQueue.async(flags: .barrier) { self._cachedCacheSeconds = newValue } }
|
||||
}
|
||||
private var isPaused: Bool {
|
||||
get { stateQueue.sync { _isPaused } }
|
||||
set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } }
|
||||
@@ -170,7 +164,6 @@ final class MPVLayerRenderer {
|
||||
|
||||
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU
|
||||
// This is better for PiP as subtitles are baked into the video
|
||||
// NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit
|
||||
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
|
||||
|
||||
// Hardware decoding with VideoToolbox
|
||||
@@ -347,9 +340,7 @@ final class MPVLayerRenderer {
|
||||
("time-pos", MPV_FORMAT_DOUBLE),
|
||||
("pause", MPV_FORMAT_FLAG),
|
||||
("track-list/count", MPV_FORMAT_INT64),
|
||||
("paused-for-cache", MPV_FORMAT_FLAG),
|
||||
("demuxer-cache-duration", MPV_FORMAT_DOUBLE),
|
||||
("current-ao", MPV_FORMAT_STRING)
|
||||
("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
]
|
||||
for (name, format) in properties {
|
||||
mpv_observe_property(handle, 0, name, format)
|
||||
@@ -493,7 +484,7 @@ final class MPVLayerRenderer {
|
||||
cachedDuration = value
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds)
|
||||
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
|
||||
}
|
||||
}
|
||||
case "time-pos":
|
||||
@@ -508,16 +499,10 @@ final class MPVLayerRenderer {
|
||||
lastProgressUpdateTime = now
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds)
|
||||
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "demuxer-cache-duration":
|
||||
var value = Double(0)
|
||||
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value)
|
||||
if status >= 0 {
|
||||
cachedCacheSeconds = value
|
||||
}
|
||||
case "pause":
|
||||
var flag: Int32 = 0
|
||||
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag)
|
||||
@@ -554,15 +539,6 @@ final class MPVLayerRenderer {
|
||||
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:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
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,21 +43,6 @@ public class MpvPlayerModule: Module {
|
||||
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
|
||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||
view.play()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import ExpoModulesCore
|
||||
import MediaPlayer
|
||||
import UIKit
|
||||
|
||||
/// Configuration for loading a video
|
||||
@@ -42,6 +41,7 @@ class MpvPlayerView: ExpoView {
|
||||
private var renderer: MPVLayerRenderer?
|
||||
private var videoContainer: UIView!
|
||||
private var pipController: PiPController?
|
||||
|
||||
let onLoad = EventDispatcher()
|
||||
let onPlaybackStateChange = EventDispatcher()
|
||||
let onProgress = EventDispatcher()
|
||||
@@ -53,14 +53,11 @@ class MpvPlayerView: ExpoView {
|
||||
private var cachedDuration: Double = 0
|
||||
private var intendedPlayState: Bool = false
|
||||
private var _isZoomedToFill: Bool = false
|
||||
|
||||
// Reference to now playing manager
|
||||
private let nowPlayingManager = MPVNowPlayingManager.shared
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupNotifications()
|
||||
setupView()
|
||||
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
|
||||
}
|
||||
|
||||
private func setupView() {
|
||||
@@ -112,77 +109,6 @@ class MpvPlayerView: ExpoView {
|
||||
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) {
|
||||
// Skip reload if same URL is already playing
|
||||
if currentURL == config.url {
|
||||
@@ -223,7 +149,6 @@ class MpvPlayerView: ExpoView {
|
||||
|
||||
func play() {
|
||||
intendedPlayState = true
|
||||
setupRemoteCommands()
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
pipController?.updatePlaybackState()
|
||||
@@ -237,17 +162,10 @@ class MpvPlayerView: ExpoView {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -374,37 +292,27 @@ class MpvPlayerView: ExpoView {
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
displayLayer.removeFromSuperlayer()
|
||||
clearNowPlayingInfo()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 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) {
|
||||
cachedPosition = position
|
||||
cachedDuration = duration
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
// Update PiP current time for progress bar
|
||||
if self.pipController?.isPictureInPictureActive == true {
|
||||
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
|
||||
}
|
||||
|
||||
|
||||
self.onProgress([
|
||||
"position": position,
|
||||
"duration": duration,
|
||||
"progress": duration > 0 ? position / duration : 0,
|
||||
"cacheSeconds": cacheSeconds,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -412,10 +320,12 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
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
|
||||
|
||||
print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)")
|
||||
// Sync timebase rate with actual playback state
|
||||
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
|
||||
self.syncNowPlaying(isPlaying: !isPaused)
|
||||
|
||||
self.onPlaybackStateChange([
|
||||
"isPaused": isPaused,
|
||||
"isPlaying": !isPaused,
|
||||
@@ -447,13 +357,6 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||
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
|
||||
|
||||
@@ -15,8 +15,6 @@ export type OnProgressEventPayload = {
|
||||
position: number;
|
||||
duration: number;
|
||||
progress: number;
|
||||
/** Seconds of video buffered ahead of current position */
|
||||
cacheSeconds: number;
|
||||
};
|
||||
|
||||
export type OnErrorEventPayload = {
|
||||
@@ -25,13 +23,6 @@ export type OnErrorEventPayload = {
|
||||
|
||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||
|
||||
export type NowPlayingMetadata = {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
albumTitle?: string;
|
||||
artworkUri?: string;
|
||||
};
|
||||
|
||||
export type MpvPlayerModuleEvents = {
|
||||
onChange: (params: ChangeEventPayload) => void;
|
||||
};
|
||||
@@ -55,8 +46,6 @@ export type VideoSource = {
|
||||
export type MpvPlayerViewProps = {
|
||||
source?: VideoSource;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Metadata for iOS Control Center and Lock Screen now playing info */
|
||||
nowPlayingMetadata?: NowPlayingMetadata;
|
||||
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
|
||||
onPlaybackStateChange?: (event: {
|
||||
nativeEvent: OnPlaybackStateChangePayload;
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
"react-native-mmkv": "4.1.1",
|
||||
"react-native-nitro-modules": "0.33.1",
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-reanimated": "~4.2.0",
|
||||
"react-native-reanimated-carousel": "4.0.3",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.18.0",
|
||||
|
||||
Reference in New Issue
Block a user