Compare commits

..

1 Commits

Author SHA1 Message Date
Uruk
620a3224fc docs: add CodeRabbit auto-summary integration to PR template
Integrates CodeRabbit's automatic summary generation feature into the pull request template by adding a trigger command and informational comment.

This enables automated PR summaries when CodeRabbit is enabled, streamlining the review process and reducing manual effort in documenting changes.
2026-01-17 18:14:08 +01:00
9 changed files with 17 additions and 146 deletions

View File

@@ -10,10 +10,13 @@
## 🔖 Summary ## 🔖 Summary
<!-- <!--
A concise description of the changes introduced by this PR. A concise description of the changes introduced by this PR.
CodeRabbit will auto-generate a summary here if enabled.
Example: Example:
Add real-time currency conversion widget to dashboard. "Add real-time currency conversion widget to dashboard."
--> -->
@coderabbitai summary
## 🏷️ Ticket / Issue ## 🏷️ Ticket / Issue
<!-- <!--
Link to the related ticket, issue or user story. Link to the related ticket, issue or user story.

View File

@@ -449,7 +449,7 @@ export default function page() {
async (data: { nativeEvent: MpvOnProgressEventPayload }) => { async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.get() || isPlaybackStopped) return;
const { position, cacheSeconds } = data.nativeEvent; const { position } = data.nativeEvent;
// MPV reports position in seconds, convert to ms // MPV reports position in seconds, convert to ms
const currentTime = position * 1000; const currentTime = position * 1000;
@@ -459,12 +459,6 @@ export default function page() {
progress.set(currentTime); 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 // Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now(); const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get(); const shouldUpdateUrl = wasJustSeeking.get();

View File

@@ -1,8 +1,5 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { useCallback, useEffect } from "react";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler"; import { Pressable } from "react-native-gesture-handler";
import GoogleCast, { import GoogleCast, {
@@ -13,7 +10,6 @@ import GoogleCast, {
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
export function Chromecast({ export function Chromecast({
@@ -28,10 +24,6 @@ export function Chromecast({
const sessionManager = GoogleCast.getSessionManager(); const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager(); const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const lastReportedProgressRef = useRef(0);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -44,53 +36,6 @@ export function Chromecast({
})(); })();
}, [client, devices, castDevice, sessionManager, discoveryManager]); }, [client, devices, castDevice, sessionManager, discoveryManager]);
// Report video progress to Jellyfin server
useEffect(() => {
if (
!api ||
!user?.Id ||
!mediaStatus ||
!mediaStatus.mediaInfo?.contentId
) {
return;
}
const streamPosition = mediaStatus.streamPosition || 0;
// Report every 10 seconds
if (Math.abs(streamPosition - lastReportedProgressRef.current) < 10) {
return;
}
const contentId = mediaStatus.mediaInfo.contentId;
const positionTicks = Math.floor(streamPosition * 10000000);
const isPaused = mediaStatus.playerState === "paused";
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
const isTranscoding = streamUrl.includes("m3u8");
const progressInfo: PlaybackProgressInfo = {
ItemId: contentId,
PositionTicks: positionTicks,
IsPaused: isPaused,
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
PlaySessionId: contentId,
};
getPlaystateApi(api)
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
.then(() => {
lastReportedProgressRef.current = streamPosition;
})
.catch((error) => {
console.error("Failed to report Chromecast progress:", error);
});
}, [
api,
user?.Id,
mediaStatus?.streamPosition,
mediaStatus?.mediaInfo?.contentId,
]);
// Android requires the cast button to be present for startDiscovery to work // Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback( const AndroidCastButton = useCallback(
() => () =>

View File

@@ -1,13 +1,10 @@
package expo.modules.mpvplayer package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.content.res.AssetManager
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import java.io.File
import java.io.FileOutputStream
/** /**
* MPV renderer that wraps libmpv for video playback. * MPV renderer that wraps libmpv for video playback.
@@ -29,7 +26,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
interface Delegate { interface Delegate {
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) fun onPositionChanged(position: Double, duration: Double)
fun onPauseChanged(isPaused: Boolean) fun onPauseChanged(isPaused: Boolean)
fun onLoadingChanged(isLoading: Boolean) fun onLoadingChanged(isLoading: Boolean)
fun onReadyToSeek() fun onReadyToSeek()
@@ -49,7 +46,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// Cached state // Cached state
private var cachedPosition: Double = 0.0 private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0 private var cachedDuration: Double = 0.0
private var cachedCacheSeconds: Double = 0.0
private var _isPaused: Boolean = true private var _isPaused: Boolean = true
private var _isLoading: Boolean = false private var _isLoading: Boolean = false
private var _playbackSpeed: Double = 1.0 private var _playbackSpeed: Double = 1.0
@@ -105,52 +101,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.create(context) MPVLib.create(context)
MPVLib.addObserver(this) 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) // Configure mpv options before initialization (based on Findroid)
MPVLib.setOptionString("vo", "gpu") MPVLib.setOptionString("vo", "gpu")
MPVLib.setOptionString("gpu-context", "android") MPVLib.setOptionString("gpu-context", "android")
@@ -174,7 +124,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.setOptionString("hr-seek-framedrop", "yes") MPVLib.setOptionString("hr-seek-framedrop", "yes")
// Subtitle settings // Subtitle settings
MPVLib.setOptionString("sub-scale-with-window", "no") MPVLib.setOptionString("sub-scale-with-window", "yes")
MPVLib.setOptionString("sub-use-margins", "no") MPVLib.setOptionString("sub-use-margins", "no")
MPVLib.setOptionString("subs-match-os-language", "yes") MPVLib.setOptionString("subs-match-os-language", "yes")
MPVLib.setOptionString("subs-fallback", "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("pause", MPV_FORMAT_FLAG)
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64) MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
// Video dimensions for PiP aspect ratio // Video dimensions for PiP aspect ratio
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64) MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
MPVLib.observeProperty("video-params/h", 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) { when (property) {
"duration" -> { "duration" -> {
cachedDuration = value cachedDuration = value
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) } mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
} }
"time-pos" -> { "time-pos" -> {
cachedPosition = value cachedPosition = value
@@ -621,12 +570,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000) val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000)
if (shouldUpdate) { if (shouldUpdate) {
lastProgressUpdateTime = now lastProgressUpdateTime = now
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) } mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
} }
} }
"demuxer-cache-duration" -> {
cachedCacheSeconds = value
}
} }
} }

View File

@@ -307,7 +307,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - MPVLayerRenderer.Delegate // MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) { override fun onPositionChanged(position: Double, duration: Double) {
cachedPosition = position cachedPosition = position
cachedDuration = duration cachedDuration = duration
@@ -319,8 +319,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
onProgress(mapOf( onProgress(mapOf(
"position" to position, "position" to position,
"duration" to duration, "duration" to duration,
"progress" to if (duration > 0) position / duration else 0.0, "progress" to if (duration > 0) position / duration else 0.0
"cacheSeconds" to cacheSeconds
)) ))
} }

View File

@@ -5,7 +5,7 @@ import CoreVideo
import AVFoundation import AVFoundation
protocol MPVLayerRendererDelegate: AnyObject { 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, didChangePause isPaused: Bool)
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)
@@ -44,7 +44,6 @@ final class MPVLayerRenderer {
// Thread-safe state for playback // Thread-safe state for playback
private var _cachedDuration: Double = 0 private var _cachedDuration: Double = 0
private var _cachedPosition: Double = 0 private var _cachedPosition: Double = 0
private var _cachedCacheSeconds: Double = 0
private var _isPaused: Bool = true private var _isPaused: Bool = true
private var _playbackSpeed: Double = 1.0 private var _playbackSpeed: Double = 1.0
private var _isLoading: Bool = false private var _isLoading: Bool = false
@@ -76,10 +75,6 @@ final class MPVLayerRenderer {
get { stateQueue.sync { _cachedPosition } } get { stateQueue.sync { _cachedPosition } }
set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } } 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 { private var isPaused: Bool {
get { stateQueue.sync { _isPaused } } get { stateQueue.sync { _isPaused } }
set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } } set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } }
@@ -169,7 +164,6 @@ final class MPVLayerRenderer {
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU // Enable composite OSD mode - renders subtitles directly onto video frames using GPU
// This is better for PiP as subtitles are baked into the video // 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")) checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
// Hardware decoding with VideoToolbox // Hardware decoding with VideoToolbox
@@ -346,8 +340,7 @@ final class MPVLayerRenderer {
("time-pos", MPV_FORMAT_DOUBLE), ("time-pos", MPV_FORMAT_DOUBLE),
("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)
] ]
for (name, format) in properties { for (name, format) in properties {
mpv_observe_property(handle, 0, name, format) mpv_observe_property(handle, 0, name, format)
@@ -491,7 +484,7 @@ final class MPVLayerRenderer {
cachedDuration = value cachedDuration = value
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } 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": case "time-pos":
@@ -506,16 +499,10 @@ final class MPVLayerRenderer {
lastProgressUpdateTime = now lastProgressUpdateTime = now
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } 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": case "pause":
var flag: Int32 = 0 var flag: Int32 = 0
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag)

View File

@@ -298,7 +298,7 @@ class MpvPlayerView: ExpoView {
// MARK: - MPVLayerRendererDelegate // MARK: - MPVLayerRendererDelegate
extension MpvPlayerView: MPVLayerRendererDelegate { extension MpvPlayerView: MPVLayerRendererDelegate {
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) { func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) {
cachedPosition = position cachedPosition = position
cachedDuration = duration cachedDuration = duration
@@ -313,7 +313,6 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
"position": position, "position": position,
"duration": duration, "duration": duration,
"progress": duration > 0 ? position / duration : 0, "progress": duration > 0 ? position / duration : 0,
"cacheSeconds": cacheSeconds,
]) ])
} }
} }

View File

@@ -15,8 +15,6 @@ export type OnProgressEventPayload = {
position: number; position: number;
duration: number; duration: number;
progress: number; progress: number;
/** Seconds of video buffered ahead of current position */
cacheSeconds: number;
}; };
export type OnErrorEventPayload = { export type OnErrorEventPayload = {