mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-29 21:31:53 +01:00
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:
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
2
.github/workflows/update-issue-form.yml
vendored
2
.github/workflows/update-issue-form.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
4
bun.lock
4
bun.lock
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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") || [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user