Merge remote-tracking branch 'origin/develop' into fix/subrip-mpv

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-01-16 13:46:15 +02:00
10 changed files with 91 additions and 53 deletions

View File

@@ -107,7 +107,7 @@ jobs:
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '24.x'

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '24.x'
cache: 'npm'

View File

@@ -531,7 +531,11 @@ export default function page() {
subtitleIndex,
isTranscoding,
);
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
const initialAudioId = getMpvAudioId(
mediaSource,
audioIndex,
isTranscoding,
);
// Calculate start position directly here to avoid timing issues
const startTicks = playbackPositionFromUrl

View File

@@ -74,7 +74,7 @@
"react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.32.1",
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3",
@@ -1678,7 +1678,7 @@
"react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="],
"react-native-nitro-modules": ["react-native-nitro-modules@0.32.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-V+Vy76e4fxRxgVGu5Uh3cBPvuFQW8fM1OUKk1mqEA/JawjhX+hxHtBhpfuvNjV0BnV/uXCIg8/eK+rTpB6tqFg=="],
"react-native-nitro-modules": ["react-native-nitro-modules@0.33.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Kdo8qiqlkGAEs7fq29i0yiZs0Gf7ucmMiFsH8PH4uzsnSGEt2CQRBJGnQKKMl9vJYL8e7rzA0TZKRwO/L8G/Sg=="],
"react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],

View File

@@ -53,6 +53,23 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
private var _isLoading: Boolean = false
private var _playbackSpeed: Double = 1.0
private var isReadyToSeek: Boolean = false
// Progress update throttling - CRITICAL for performance!
// DO NOT REMOVE THIS THROTTLE - it is essential for battery life and CPU efficiency.
//
// Without throttling, time-pos fires every video frame (24+ times/sec at 24fps).
// Each update crosses the React Native JS bridge, which is expensive on mobile.
// Even if the JS side does nothing, 24+ bridge calls/sec wastes CPU and battery.
//
// Throttling to 1 update/sec during normal playback is sufficient for:
// - Progress bar updates (users can't perceive 1-second granularity)
// - Playback position tracking
// - Any JS-side logic that needs current position
//
// During seeking, we bypass the throttle for responsive scrubbing.
// This optimization reduced CPU usage by ~50% for downloaded file playback.
private var lastProgressUpdateTime: Long = 0
private var _isSeeking: Boolean = false
// Video dimensions
private var _videoWidth: Int = 0
@@ -565,7 +582,13 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
"time-pos" -> {
cachedPosition = value
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
// Always update immediately when seeking, otherwise throttle to once per second
val now = System.currentTimeMillis()
val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000)
if (shouldUpdate) {
lastProgressUpdateTime = now
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
}
}
}
}
@@ -597,7 +620,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
}
MPVLib.MPV_EVENT_SEEK -> {
// Seek started - show loading indicator
// Seek started - show loading indicator and enable immediate progress updates
_isSeeking = true
if (!_isLoading) {
_isLoading = true
mainHandler.post { delegate?.onLoadingChanged(true) }
@@ -605,6 +629,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
MPVLib.MPV_EVENT_PLAYBACK_RESTART -> {
// Video playback has started/restarted (including after seek)
_isSeeking = false
if (_isLoading) {
_isLoading = false
mainHandler.post { delegate?.onLoadingChanged(false) }

View File

@@ -33,23 +33,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
companion object {
private const val TAG = "MpvPlayerView"
/**
* Detect if running on an Android emulator.
* MPV player has EGL/OpenGL compatibility issues on emulators.
*/
private fun isEmulator(): Boolean {
return (Build.FINGERPRINT.startsWith("generic")
|| Build.FINGERPRINT.startsWith("unknown")
|| Build.MODEL.contains("google_sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK built for x86")
|| Build.MANUFACTURER.contains("Genymotion")
|| (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
|| "google_sdk" == Build.PRODUCT
|| Build.HARDWARE.contains("goldfish")
|| Build.HARDWARE.contains("ranchu"))
}
}
// Event dispatchers
@@ -104,21 +87,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
// Start the renderer (skip on emulators to avoid EGL crashes)
if (isEmulator()) {
Log.w(TAG, "Running on emulator - MPV player disabled due to EGL/OpenGL compatibility issues")
// Don't start renderer on emulator, will show error when trying to play
} else {
try {
renderer?.start()
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
}
// Start the renderer
try {
renderer?.start()
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
}
}
private var isOnEmulator: Boolean = isEmulator()
// MARK: - SurfaceHolder.Callback
@@ -149,13 +125,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Video Loading
fun loadVideo(config: VideoLoadConfig) {
// Block video loading on emulators
if (isOnEmulator) {
Log.w(TAG, "Cannot load video on emulator - MPV player not supported")
onError(mapOf("error" to "MPV player is not supported on emulators. Please test on a real device."))
return
}
// Skip reload if same URL is already playing
if (currentUrl == config.url) {
return

View File

@@ -48,6 +48,23 @@ final class MPVLayerRenderer {
private var _playbackSpeed: Double = 1.0
private var _isLoading: Bool = false
private var _isReadyToSeek: Bool = false
private var _isSeeking: Bool = false
// Progress update throttling - CRITICAL for performance!
// DO NOT REMOVE THIS THROTTLE - it is essential for battery life and CPU efficiency.
//
// Without throttling, time-pos fires every video frame (24+ times/sec at 24fps).
// Each update crosses the React Native JS bridge, which is expensive on mobile.
// Even if the JS side does nothing, 24+ bridge calls/sec wastes CPU and battery.
//
// Throttling to 1 update/sec during normal playback is sufficient for:
// - Progress bar updates (users can't perceive 1-second granularity)
// - Playback position tracking
// - Any JS-side logic that needs current position
//
// During seeking, we bypass the throttle for responsive scrubbing.
// This optimization reduced CPU usage by ~50% for downloaded file playback.
private var lastProgressUpdateTime: CFAbsoluteTime = 0
// Thread-safe accessors
private var cachedDuration: Double {
@@ -74,6 +91,10 @@ final class MPVLayerRenderer {
get { stateQueue.sync { _isReadyToSeek } }
set { stateQueue.async(flags: .barrier) { self._isReadyToSeek = newValue } }
}
private var isSeeking: Bool {
get { stateQueue.sync { _isSeeking } }
set { stateQueue.async(flags: .barrier) { self._isSeeking = newValue } }
}
var isPausedState: Bool {
return isPaused
@@ -408,7 +429,8 @@ final class MPVLayerRenderer {
}
case MPV_EVENT_SEEK:
// Seek started - show loading indicator
// Seek started - show loading indicator and enable immediate progress updates
isSeeking = true
if !isLoading {
isLoading = true
DispatchQueue.main.async { [weak self] in
@@ -419,6 +441,7 @@ final class MPVLayerRenderer {
case MPV_EVENT_PLAYBACK_RESTART:
// Video playback has started/restarted (including after seek)
isSeeking = false
if isLoading {
isLoading = false
DispatchQueue.main.async { [weak self] in
@@ -469,9 +492,15 @@ final class MPVLayerRenderer {
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value)
if status >= 0 {
cachedPosition = value
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
// Always update immediately when seeking, otherwise throttle to once per second
let now = CFAbsoluteTimeGetCurrent()
let shouldUpdate = isSeeking || (now - lastProgressUpdateTime >= 1.0)
if shouldUpdate {
lastProgressUpdateTime = now
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
}
}
}
case "pause":

View File

@@ -94,7 +94,7 @@
"react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.32.1",
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3",

View File

@@ -147,7 +147,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}, [api, deviceId, headers]);
const pollQuickConnect = useCallback(async () => {
if (!api || !secret) return;
if (!api || !secret || !jellyfin) return;
try {
const response = await api.axiosInstance.get(
@@ -169,8 +169,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
const { AccessToken, User } = authResponse.data;
api.accessToken = AccessToken;
setUser(User);
setApi(jellyfin.createApi(api.basePath, AccessToken));
storage.set("token", AccessToken);
storage.set("user", JSON.stringify(User));
return true;
@@ -186,7 +186,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
console.error("Error polling Quick Connect:", error);
throw error;
}
}, [api, secret, headers]);
}, [api, secret, headers, jellyfin]);
useEffect(() => {
(async () => {

View File

@@ -91,21 +91,32 @@ export const getMpvSubtitleId = (
/**
* Calculate the MPV track ID for a given Jellyfin audio index.
*
* Audio tracks are simpler - they're always in MPV (no burn-in like image subs).
* For direct play: Audio tracks map to their position in the file (1-based).
* For transcoding: Only ONE audio track exists in the HLS stream (the selected one),
* so we should return 1 or undefined to use the default track.
*
* MPV track IDs are 1-based.
*
* @param mediaSource - The media source containing audio streams
* @param jellyfinAudioIndex - The Jellyfin server-side audio index
* @param isTranscoding - Whether the stream is being transcoded
* @returns MPV track ID (1-based), or undefined if not found
*/
export const getMpvAudioId = (
mediaSource: MediaSourceInfo | null | undefined,
jellyfinAudioIndex: number | undefined,
isTranscoding: boolean,
): number | undefined => {
if (jellyfinAudioIndex === undefined) {
return undefined;
}
// When transcoding, Jellyfin only includes the selected audio track in the HLS stream.
// So there's only 1 audio track - no need to specify an ID.
if (isTranscoding) {
return undefined;
}
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];