Compare commits

...

8 Commits

Author SHA1 Message Date
Lance Chant
9c0de94247 fix: text ui scaling
Made text UI scaling follow OS level scailing to a limit to stop
overlapping issues

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-19 14:59:11 +02:00
Fredrik Burmester
358e00d8b7 fix(player): resolve tvOS freeze on player exit by reordering mpv options
Some checks are pending
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
2026-01-19 08:41:52 +01:00
lance chant
c7077bbcfe fix: subrip mpv (#1375)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-18 19:35:14 +01:00
Alex
c0f25a2b8b Add caching progress in seek slider bar (#1376) 2026-01-18 19:34:39 +01:00
renovate[bot]
36304ad58e chore(deps): Update dependency react-native-nitro-modules to v0.33.1 (#1340)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 16:48:51 +01:00
renovate[bot]
baeb83581e chore(deps): Update actions/setup-node action to v6.2.0 (#1372)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 15:22:19 +01:00
Fredrik Burmester
05b7a4c50d fix: downloads should work when connecting through QC 2026-01-15 07:54:08 +01:00
Fredrik Burmester
28b67f3ad6 fix(mpv): handle audio track selection for transcoded streams on iOS 2026-01-15 07:53:15 +01:00
27 changed files with 184 additions and 49 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

@@ -449,7 +449,7 @@ export default function page() {
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { position } = data.nativeEvent;
const { position, cacheSeconds } = data.nativeEvent;
// MPV reports position in seconds, convert to ms
const currentTime = position * 1000;
@@ -459,6 +459,12 @@ 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();
@@ -531,7 +537,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

@@ -144,7 +144,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
${colorClasses}
${className}`}
>
<Text className={`${textColorClass} text-xl font-bold`}>
<Text
className={`${textColorClass} text-xl font-bold`}
ellipsizeMode='tail'
>
{children}
</Text>
</View>
@@ -186,6 +189,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
${iconRight ? "mr-2" : ""}
${iconLeft ? "ml-2" : ""}
`}
ellipsizeMode='tail'
>
{children}
</Text>

View File

@@ -17,7 +17,7 @@ const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
return (
<View className='mt-2'>
<Button onPress={startDiscovery} color='black'>
<Text className='text-white text-center'>
<Text maxFontSizeMultiplier={1.2} className='text-white text-center'>
{isSearching
? t("server.searching")
: t("server.search_for_local_servers")}

View File

@@ -532,18 +532,24 @@ export const PlayButton: React.FC<Props> = ({
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
>
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
<Animated.Text
style={[animatedTextStyle, { fontWeight: "bold" }]}
maxFontSizeMultiplier={1.2}
>
{runtimeTicksToMinutes(
(item?.RunTimeTicks || 0) -
(item?.UserData?.PlaybackPositionTicks || 0),
)}
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Animated.Text style={animatedTextStyle} maxFontSizeMultiplier={1.2}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Animated.Text
style={animatedTextStyle}
maxFontSizeMultiplier={1.2}
>
<Feather name='cast' size={22} />
<CastButton tintColor='transparent' />
</Animated.Text>

View File

@@ -198,10 +198,13 @@ export const PlayButton: React.FC<Props> = ({
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
>
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
<Animated.Text
style={[animatedTextStyle, { fontWeight: "bold" }]}
maxFontSizeMultiplier={1.2}
>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Animated.Text style={animatedTextStyle} maxFontSizeMultiplier={1.2}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
</View>

View File

@@ -13,6 +13,7 @@ export function ThemedText({
}: ThemedTextProps) {
return (
<Text
maxFontSizeMultiplier={1.3}
style={[
{ color: "white" },
type === "default" ? styles.default : undefined,

View File

@@ -736,6 +736,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
maxFontSizeMultiplier={1.2}
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
fontSize: GENRES_FONT_SIZE,
@@ -801,6 +802,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
maxFontSizeMultiplier={1.2}
numberOfLines={OVERVIEW_MAX_LINES}
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,

View File

@@ -27,7 +27,7 @@ export function Input(props: InputProps) {
${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-transparent"}
text-white ${extraClassName}
`}
allowFontScaling={false}
maxFontSizeMultiplier={1.2}
style={[
style,
{
@@ -45,7 +45,7 @@ export function Input(props: InputProps) {
<TextInput
ref={inputRef}
className='p-4 rounded-xl bg-neutral-900'
allowFontScaling={false}
maxFontSizeMultiplier={1.2}
style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"}
clearButtonMode='while-editing'

View File

@@ -19,7 +19,9 @@ export const SectionHeader: React.FC<Props> = ({
return (
<View className='px-4 flex flex-row items-center justify-between mb-2'>
<Text className='text-lg font-bold text-neutral-100'>{title}</Text>
<Text className='text-lg font-bold text-neutral-100' ellipsizeMode='tail'>
{title}
</Text>
{shouldShowAction && (
<TouchableOpacity
onPress={onPressAction}
@@ -29,6 +31,7 @@ export const SectionHeader: React.FC<Props> = ({
className='py-1 pl-3'
>
<Text
ellipsizeMode='tail'
style={{
color: actionDisabled ? "rgba(255,255,255,0.4)" : Colors.primary,
}}

View File

@@ -4,7 +4,7 @@ export function Text(props: TextProps) {
if (Platform.isTV)
return (
<RNText
allowFontScaling={false}
maxFontSizeMultiplier={1.3}
style={[{ color: "white" }, style]}
{...otherProps}
/>
@@ -12,7 +12,7 @@ export function Text(props: TextProps) {
return (
<RNText
allowFontScaling={false}
maxFontSizeMultiplier={1.3}
style={[{ color: "white" }, style]}
{...otherProps}
/>

View File

@@ -175,10 +175,16 @@ export const Favorites = () => {
contentFit='contain'
source={heart}
/>
<Text className='text-xl font-semibold text-white mb-2'>
<Text
maxFontSizeMultiplier={1.2}
className='text-xl font-semibold text-white mb-2'
>
{t("favorites.noDataTitle")}
</Text>
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
<Text
maxFontSizeMultiplier={1.2}
className='text-base text-white/70 text-center max-w-xs px-4'
>
{t("favorites.noData")}
</Text>
</View>

View File

@@ -11,12 +11,12 @@ export const KefinTweaksSettings = () => {
return (
<View className=''>
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
<Text maxFontSizeMultiplier={1.2} className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
</Text>
<View className='flex flex-row items-center justify-between mt-2'>
<Text className='text-white'>
<Text maxFontSizeMultiplier={1.2} className='text-white'>
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
</Text>

View File

@@ -19,7 +19,9 @@ const SkipButton: React.FC<SkipButtonProps> = ({
onPress={onPress}
className='bg-black/60 rounded-md px-3 py-3 border border-neutral-900'
>
<Text className='text-white font-bold'>{buttonText}</Text>
<Text maxFontSizeMultiplier={1.2} className='text-white font-bold'>
{buttonText}
</Text>
</TouchableOpacity>
</View>
);

View File

@@ -102,6 +102,7 @@ const SliderScrubber: React.FC<SliderScrubberProps> = ({
contentFit='cover'
/>
<Text
maxFontSizeMultiplier={1.2}
style={{
position: "absolute",
bottom: 5,
@@ -126,10 +127,16 @@ const SliderScrubber: React.FC<SliderScrubberProps> = ({
maximumValue={max}
/>
<View className='flex flex-row items-center justify-between mt-0.5'>
<Text className='text-[12px] text-neutral-400'>
<Text
maxFontSizeMultiplier={1.2}
className='text-[12px] text-neutral-400'
>
{formatTimeString(currentTime, "ms")}
</Text>
<Text className='text-[12px] text-neutral-400'>
<Text
maxFontSizeMultiplier={1.2}
className='text-[12px] text-neutral-400'
>
-{formatTimeString(remainingTime, "ms")}
</Text>
</View>

View File

@@ -195,6 +195,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
<View style={styles.infoBox}>
{playMethod && (
<Text
maxFontSizeMultiplier={1.2}
style={[
styles.infoText,
{ color: getPlayMethodColor(playMethod) },
@@ -204,28 +205,31 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
</Text>
)}
{transcodeReasons && transcodeReasons.length > 0 && (
<Text style={[styles.infoText, styles.reasonText]}>
<Text
maxFontSizeMultiplier={1.2}
style={[styles.infoText, styles.reasonText]}
>
{transcodeReasons.map(formatTranscodeReason).join(", ")}
</Text>
)}
{info?.videoWidth && info?.videoHeight && (
<Text style={styles.infoText}>
<Text maxFontSizeMultiplier={1.2} style={styles.infoText}>
{info.videoWidth}x{info.videoHeight}
</Text>
)}
{info?.videoCodec && (
<Text style={styles.infoText}>
<Text maxFontSizeMultiplier={1.2} style={styles.infoText}>
Video: {formatCodec(info.videoCodec)}
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
</Text>
)}
{info?.audioCodec && (
<Text style={styles.infoText}>
<Text maxFontSizeMultiplier={1.2} style={styles.infoText}>
Audio: {formatCodec(info.audioCodec)}
</Text>
)}
{(info?.videoBitrate || info?.audioBitrate) && (
<Text style={styles.infoText}>
<Text maxFontSizeMultiplier={1.2} style={styles.infoText}>
Bitrate:{" "}
{info.videoBitrate
? formatBitrate(info.videoBitrate)
@@ -235,17 +239,22 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
</Text>
)}
{info?.cacheSeconds !== undefined && (
<Text style={styles.infoText}>
<Text maxFontSizeMultiplier={1.2} style={styles.infoText}>
Buffer: {info.cacheSeconds.toFixed(1)}s
</Text>
)}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[styles.infoText, styles.warningText]}>
<Text
maxFontSizeMultiplier={1.2}
style={[styles.infoText, styles.warningText]}
>
Dropped: {info.droppedFrames} frames
</Text>
)}
{!info && !playMethod && (
<Text style={styles.infoText}>Loading...</Text>
<Text maxFontSizeMultiplier={1.2} style={styles.infoText}>
Loading...
</Text>
)}
</View>
</Animated.View>

Binary file not shown.

View File

@@ -1,10 +1,13 @@
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.
@@ -26,7 +29,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
interface Delegate {
fun onPositionChanged(position: Double, duration: Double)
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
fun onPauseChanged(isPaused: Boolean)
fun onLoadingChanged(isLoading: Boolean)
fun onReadyToSeek()
@@ -46,6 +49,7 @@ 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
@@ -101,6 +105,52 @@ 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")
@@ -124,7 +174,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.setOptionString("hr-seek-framedrop", "yes")
// Subtitle settings
MPVLib.setOptionString("sub-scale-with-window", "yes")
MPVLib.setOptionString("sub-scale-with-window", "no")
MPVLib.setOptionString("sub-use-margins", "no")
MPVLib.setOptionString("subs-match-os-language", "yes")
MPVLib.setOptionString("subs-fallback", "yes")
@@ -283,6 +333,7 @@ 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)
@@ -561,7 +612,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
when (property) {
"duration" -> {
cachedDuration = value
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
}
"time-pos" -> {
cachedPosition = value
@@ -570,9 +621,12 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000)
if (shouldUpdate) {
lastProgressUpdateTime = now
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
}
}
"demuxer-cache-duration" -> {
cachedCacheSeconds = value
}
}
}

View File

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

View File

@@ -5,7 +5,7 @@ import CoreVideo
import AVFoundation
protocol MPVLayerRendererDelegate: AnyObject {
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double)
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double)
func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool)
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
@@ -44,6 +44,7 @@ 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
@@ -75,6 +76,10 @@ 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 } }
@@ -164,6 +169,7 @@ 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
@@ -340,7 +346,8 @@ final class MPVLayerRenderer {
("time-pos", MPV_FORMAT_DOUBLE),
("pause", MPV_FORMAT_FLAG),
("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 {
mpv_observe_property(handle, 0, name, format)
@@ -484,7 +491,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)
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds)
}
}
case "time-pos":
@@ -499,10 +506,16 @@ 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)
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds)
}
}
}
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)

View File

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

View File

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

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") || [];