mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 02:04:35 +01:00
Merge remote-tracking branch 'origin/develop' into feat/android/choose-download-location
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
@@ -1,108 +0,0 @@
|
||||
import { ViewStyle } from "react-native";
|
||||
|
||||
export type PlaybackStatePayload = {
|
||||
nativeEvent: {
|
||||
target: number;
|
||||
state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error";
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isBuffering: boolean;
|
||||
isPlaying: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ProgressUpdatePayload = {
|
||||
nativeEvent: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
isBuffering: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type VideoLoadStartPayload = {
|
||||
nativeEvent: {
|
||||
target: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PipStartedPayload = {
|
||||
nativeEvent: {
|
||||
pipStarted: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type VideoStateChangePayload = PlaybackStatePayload;
|
||||
|
||||
export type VideoProgressPayload = ProgressUpdatePayload;
|
||||
|
||||
export type VlcPlayerSource = {
|
||||
uri: string;
|
||||
type?: string;
|
||||
isNetwork?: boolean;
|
||||
autoplay?: boolean;
|
||||
startPosition?: number;
|
||||
externalSubtitles?: { name: string; DeliveryUrl: string }[];
|
||||
initOptions?: any[];
|
||||
mediaOptions?: { [key: string]: any };
|
||||
};
|
||||
|
||||
export type TrackInfo = {
|
||||
name: string;
|
||||
index: number;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
export type ChapterInfo = {
|
||||
name: string;
|
||||
timeOffset: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
export type NowPlayingMetadata = {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
albumTitle?: string;
|
||||
artworkUri?: string;
|
||||
};
|
||||
|
||||
export type VlcPlayerViewProps = {
|
||||
source: VlcPlayerSource;
|
||||
style?: ViewStyle | ViewStyle[];
|
||||
progressUpdateInterval?: number;
|
||||
paused?: boolean;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
videoAspectRatio?: string;
|
||||
nowPlayingMetadata?: NowPlayingMetadata;
|
||||
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
||||
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
||||
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
|
||||
onVideoError?: (event: PlaybackStatePayload) => void;
|
||||
onPipStarted?: (event: PipStartedPayload) => void;
|
||||
};
|
||||
|
||||
export interface VlcPlayerViewRef {
|
||||
startPictureInPicture: () => Promise<void>;
|
||||
play: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
seekTo: (time: number) => Promise<void>;
|
||||
setAudioTrack: (trackIndex: number) => Promise<void>;
|
||||
getAudioTracks: () => Promise<TrackInfo[] | null>;
|
||||
setSubtitleTrack: (trackIndex: number) => Promise<void>;
|
||||
getSubtitleTracks: () => Promise<TrackInfo[] | null>;
|
||||
setSubtitleDelay: (delay: number) => Promise<void>;
|
||||
setAudioDelay: (delay: number) => Promise<void>;
|
||||
takeSnapshot: (path: string, width: number, height: number) => Promise<void>;
|
||||
setRate: (rate: number) => Promise<void>;
|
||||
nextChapter: () => Promise<void>;
|
||||
previousChapter: () => Promise<void>;
|
||||
getChapters: () => Promise<ChapterInfo[] | null>;
|
||||
setVideoCropGeometry: (cropGeometry: string | null) => Promise<void>;
|
||||
getVideoCropGeometry: () => Promise<string | null>;
|
||||
setSubtitleURL: (url: string) => Promise<void>;
|
||||
setVideoAspectRatio: (aspectRatio: string | null) => Promise<void>;
|
||||
setVideoScaleFactor: (scaleFactor: number) => Promise<void>;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { requireNativeViewManager } from "expo-modules-core";
|
||||
import * as React from "react";
|
||||
import { ViewStyle } from "react-native";
|
||||
import type {
|
||||
VlcPlayerSource,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
} from "./VlcPlayer.types";
|
||||
|
||||
interface NativeViewRef extends VlcPlayerViewRef {
|
||||
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
|
||||
}
|
||||
|
||||
const VLCViewManager = requireNativeViewManager("VlcPlayer");
|
||||
|
||||
// Create a forwarded ref version of the native view
|
||||
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
|
||||
(props, ref) => {
|
||||
return <VLCViewManager {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
(props, ref) => {
|
||||
const nativeRef = React.useRef<NativeViewRef>(null);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
startPictureInPicture: async () => {
|
||||
await nativeRef.current?.startPictureInPicture();
|
||||
},
|
||||
play: async () => {
|
||||
await nativeRef.current?.play();
|
||||
},
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
stop: async () => {
|
||||
await nativeRef.current?.stop();
|
||||
},
|
||||
seekTo: async (time: number) => {
|
||||
await nativeRef.current?.seekTo(time);
|
||||
},
|
||||
setAudioTrack: async (trackIndex: number) => {
|
||||
await nativeRef.current?.setAudioTrack(trackIndex);
|
||||
},
|
||||
getAudioTracks: async () => {
|
||||
const tracks = await nativeRef.current?.getAudioTracks();
|
||||
return tracks ?? null;
|
||||
},
|
||||
setSubtitleTrack: async (trackIndex: number) => {
|
||||
await nativeRef.current?.setSubtitleTrack(trackIndex);
|
||||
},
|
||||
getSubtitleTracks: async () => {
|
||||
const tracks = await nativeRef.current?.getSubtitleTracks();
|
||||
return tracks ?? null;
|
||||
},
|
||||
setSubtitleDelay: async (delay: number) => {
|
||||
await nativeRef.current?.setSubtitleDelay(delay);
|
||||
},
|
||||
setAudioDelay: async (delay: number) => {
|
||||
await nativeRef.current?.setAudioDelay(delay);
|
||||
},
|
||||
takeSnapshot: async (path: string, width: number, height: number) => {
|
||||
await nativeRef.current?.takeSnapshot(path, width, height);
|
||||
},
|
||||
setRate: async (rate: number) => {
|
||||
await nativeRef.current?.setRate(rate);
|
||||
},
|
||||
nextChapter: async () => {
|
||||
await nativeRef.current?.nextChapter();
|
||||
},
|
||||
previousChapter: async () => {
|
||||
await nativeRef.current?.previousChapter();
|
||||
},
|
||||
getChapters: async () => {
|
||||
const chapters = await nativeRef.current?.getChapters();
|
||||
return chapters ?? null;
|
||||
},
|
||||
setVideoCropGeometry: async (geometry: string | null) => {
|
||||
await nativeRef.current?.setVideoCropGeometry(geometry);
|
||||
},
|
||||
getVideoCropGeometry: async () => {
|
||||
const geometry = await nativeRef.current?.getVideoCropGeometry();
|
||||
return geometry ?? null;
|
||||
},
|
||||
setSubtitleURL: async (url: string) => {
|
||||
await nativeRef.current?.setSubtitleURL(url);
|
||||
},
|
||||
setVideoAspectRatio: async (aspectRatio: string | null) => {
|
||||
await nativeRef.current?.setVideoAspectRatio(aspectRatio);
|
||||
},
|
||||
setVideoScaleFactor: async (scaleFactor: number) => {
|
||||
await nativeRef.current?.setVideoScaleFactor(scaleFactor);
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
source,
|
||||
style,
|
||||
progressUpdateInterval = 500,
|
||||
paused,
|
||||
muted,
|
||||
volume,
|
||||
videoAspectRatio,
|
||||
nowPlayingMetadata,
|
||||
onVideoLoadStart,
|
||||
onVideoStateChange,
|
||||
onVideoProgress,
|
||||
onVideoLoadEnd,
|
||||
onVideoError,
|
||||
onPipStarted,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const processedSource: VlcPlayerSource =
|
||||
typeof source === "string"
|
||||
? ({ uri: source } as unknown as VlcPlayerSource)
|
||||
: source;
|
||||
|
||||
if (processedSource.startPosition !== undefined) {
|
||||
processedSource.startPosition = Math.floor(processedSource.startPosition);
|
||||
}
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
{...otherProps}
|
||||
ref={nativeRef}
|
||||
source={processedSource}
|
||||
style={[{ width: "100%", height: "100%" }, style as ViewStyle]}
|
||||
progressUpdateInterval={progressUpdateInterval}
|
||||
paused={paused}
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
videoAspectRatio={videoAspectRatio}
|
||||
nowPlayingMetadata={nowPlayingMetadata}
|
||||
onVideoLoadStart={onVideoLoadStart}
|
||||
onVideoLoadEnd={onVideoLoadEnd}
|
||||
onVideoStateChange={onVideoStateChange}
|
||||
onVideoProgress={onVideoProgress}
|
||||
onVideoError={onVideoError}
|
||||
onPipStarted={onPipStarted}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default VlcPlayerView;
|
||||
@@ -1,46 +1,20 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'kotlin-android'
|
||||
}
|
||||
apply plugin: 'expo-module-gradle-plugin'
|
||||
|
||||
group = 'expo.modules.backgrounddownloader'
|
||||
version = '1.0.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||
|
||||
apply from: expoModulesCorePlugin
|
||||
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useDefaultAndroidSdkVersions()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
expoModule {
|
||||
canBePublished false
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.backgrounddownloader"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||
implementation "com.squareup.okhttp3:okhttp:5.3.0"
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
}
|
||||
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name=".DownloadService"
|
||||
|
||||
@@ -5,44 +5,104 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
|
||||
class DownloadService : Service() {
|
||||
private val TAG = "DownloadService"
|
||||
private val NOTIFICATION_ID = 1001
|
||||
private val CHANNEL_ID = "download_channel"
|
||||
|
||||
|
||||
// Time threshold to detect if we're in boot context (10 minutes after boot)
|
||||
private val BOOT_THRESHOLD_MS = 10 * 60 * 1000L
|
||||
|
||||
private val binder = DownloadServiceBinder()
|
||||
private var activeDownloadCount = 0
|
||||
private var currentDownloadTitle = "Preparing download..."
|
||||
private var currentProgress = 0
|
||||
|
||||
private var isForegroundStarted = false
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
inner class DownloadServiceBinder : Binder() {
|
||||
fun getService(): DownloadService = this@DownloadService
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "DownloadService created")
|
||||
createNotificationChannel()
|
||||
|
||||
val pm = getSystemService(PowerManager::class.java)
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Streamyfin::DownloadWakeLock")
|
||||
wakeLock?.acquire()
|
||||
|
||||
Log.d(TAG, "Wake lock acquired")
|
||||
}
|
||||
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
Log.d(TAG, "DownloadService bound")
|
||||
return binder
|
||||
}
|
||||
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "DownloadService started")
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
|
||||
// On Android 15+, dataSync foreground services cannot be started from BOOT_COMPLETED context
|
||||
// Check if we're likely in a boot context and skip foreground start if so
|
||||
if (Build.VERSION.SDK_INT >= 35 && isLikelyBootContext()) {
|
||||
Log.w(TAG, "Skipping foreground start - likely boot context on Android 15+")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
startForegroundSafely()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're likely in a boot context by checking system uptime.
|
||||
* If the system has been up for less than the threshold, we might be in boot context.
|
||||
*/
|
||||
private fun isLikelyBootContext(): Boolean {
|
||||
val uptimeMs = SystemClock.elapsedRealtime()
|
||||
return uptimeMs < BOOT_THRESHOLD_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Start foreground service safely with proper service type for Android 14+
|
||||
*/
|
||||
private fun startForegroundSafely() {
|
||||
if (isForegroundStarted) return
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
createNotification(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
}
|
||||
isForegroundStarted = true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start foreground service", e)
|
||||
// If we can't start foreground, stop the service
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
wakeLock?.let { if (it.isHeld) it.release() }
|
||||
Log.d(TAG, "Wake lock released")
|
||||
Log.d(TAG, "DownloadService destroyed")
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -86,7 +146,7 @@ class DownloadService : Service() {
|
||||
activeDownloadCount++
|
||||
Log.d(TAG, "Download started, active count: $activeDownloadCount")
|
||||
if (activeDownloadCount == 1) {
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
startForegroundSafely()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +154,10 @@ class DownloadService : Service() {
|
||||
activeDownloadCount = maxOf(0, activeDownloadCount - 1)
|
||||
Log.d(TAG, "Download stopped, active count: $activeDownloadCount")
|
||||
if (activeDownloadCount == 0) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
if (isForegroundStarted) {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
isForegroundStarted = false
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
6
modules/glass-poster/expo-module.config.json
Normal file
6
modules/glass-poster/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["GlassPosterModule"]
|
||||
}
|
||||
}
|
||||
8
modules/glass-poster/index.ts
Normal file
8
modules/glass-poster/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Glass Poster - Native SwiftUI glass effect for tvOS 26+
|
||||
|
||||
export * from "./src/GlassPoster.types";
|
||||
export {
|
||||
default as GlassPosterModule,
|
||||
isGlassEffectAvailable,
|
||||
} from "./src/GlassPosterModule";
|
||||
export { default as GlassPosterView } from "./src/GlassPosterView";
|
||||
23
modules/glass-poster/ios/GlassPoster.podspec
Normal file
23
modules/glass-poster/ios/GlassPoster.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'GlassPoster'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Native SwiftUI glass effect poster for tvOS'
|
||||
s.description = 'Provides Liquid Glass effect poster cards for tvOS 26+'
|
||||
s.author = 'Streamyfin'
|
||||
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_VERSION' => '5.9'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift}"
|
||||
end
|
||||
94
modules/glass-poster/ios/GlassPosterExpoView.swift
Normal file
94
modules/glass-poster/ios/GlassPosterExpoView.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import ExpoModulesCore
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
/// Observable state that SwiftUI can watch for changes without rebuilding the entire view
|
||||
class GlassPosterState: ObservableObject {
|
||||
@Published var imageUrl: String? = nil
|
||||
@Published var aspectRatio: Double = 10.0 / 15.0
|
||||
@Published var cornerRadius: Double = 24
|
||||
@Published var progress: Double = 0
|
||||
@Published var showWatchedIndicator: Bool = false
|
||||
@Published var isFocused: Bool = false
|
||||
@Published var width: Double = 260
|
||||
}
|
||||
|
||||
/// ExpoView wrapper that hosts the SwiftUI GlassPosterView
|
||||
class GlassPosterExpoView: ExpoView {
|
||||
private var hostingController: UIHostingController<GlassPosterViewWrapper>?
|
||||
private let state = GlassPosterState()
|
||||
|
||||
// Stored dimensions for intrinsic content size
|
||||
private var posterWidth: CGFloat = 260
|
||||
private var posterAspectRatio: CGFloat = 10.0 / 15.0
|
||||
|
||||
// Event dispatchers
|
||||
let onLoad = EventDispatcher()
|
||||
let onError = EventDispatcher()
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupHostingController()
|
||||
}
|
||||
|
||||
private func setupHostingController() {
|
||||
let wrapper = GlassPosterViewWrapper(state: state)
|
||||
let hostingController = UIHostingController(rootView: wrapper)
|
||||
hostingController.view.backgroundColor = .clear
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(hostingController.view)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
hostingController.view.topAnchor.constraint(equalTo: topAnchor),
|
||||
hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
self.hostingController = hostingController
|
||||
}
|
||||
|
||||
// Override intrinsic content size for proper React Native layout
|
||||
override var intrinsicContentSize: CGSize {
|
||||
let height = posterWidth / posterAspectRatio
|
||||
return CGSize(width: posterWidth, height: height)
|
||||
}
|
||||
|
||||
// MARK: - Property Setters
|
||||
// These now update the observable state object directly.
|
||||
// SwiftUI observes state changes and only re-renders affected views.
|
||||
|
||||
func setImageUrl(_ url: String?) {
|
||||
state.imageUrl = url
|
||||
}
|
||||
|
||||
func setAspectRatio(_ ratio: Double) {
|
||||
state.aspectRatio = ratio
|
||||
posterAspectRatio = CGFloat(ratio)
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
func setWidth(_ width: Double) {
|
||||
state.width = width
|
||||
posterWidth = CGFloat(width)
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
func setCornerRadius(_ radius: Double) {
|
||||
state.cornerRadius = radius
|
||||
}
|
||||
|
||||
func setProgress(_ progress: Double) {
|
||||
state.progress = progress
|
||||
}
|
||||
|
||||
func setShowWatchedIndicator(_ show: Bool) {
|
||||
state.showWatchedIndicator = show
|
||||
}
|
||||
|
||||
func setIsFocused(_ focused: Bool) {
|
||||
state.isFocused = focused
|
||||
}
|
||||
}
|
||||
50
modules/glass-poster/ios/GlassPosterModule.swift
Normal file
50
modules/glass-poster/ios/GlassPosterModule.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class GlassPosterModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("GlassPoster")
|
||||
|
||||
// Check if glass effect is available (tvOS 26+)
|
||||
Function("isGlassEffectAvailable") { () -> Bool in
|
||||
#if os(tvOS)
|
||||
if #available(tvOS 26.0, *) {
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
// Native view component
|
||||
View(GlassPosterExpoView.self) {
|
||||
Prop("imageUrl") { (view: GlassPosterExpoView, url: String?) in
|
||||
view.setImageUrl(url)
|
||||
}
|
||||
|
||||
Prop("aspectRatio") { (view: GlassPosterExpoView, ratio: Double) in
|
||||
view.setAspectRatio(ratio)
|
||||
}
|
||||
|
||||
Prop("cornerRadius") { (view: GlassPosterExpoView, radius: Double) in
|
||||
view.setCornerRadius(radius)
|
||||
}
|
||||
|
||||
Prop("progress") { (view: GlassPosterExpoView, progress: Double) in
|
||||
view.setProgress(progress)
|
||||
}
|
||||
|
||||
Prop("showWatchedIndicator") { (view: GlassPosterExpoView, show: Bool) in
|
||||
view.setShowWatchedIndicator(show)
|
||||
}
|
||||
|
||||
Prop("isFocused") { (view: GlassPosterExpoView, focused: Bool) in
|
||||
view.setIsFocused(focused)
|
||||
}
|
||||
|
||||
Prop("width") { (view: GlassPosterExpoView, width: Double) in
|
||||
view.setWidth(width)
|
||||
}
|
||||
|
||||
Events("onLoad", "onError")
|
||||
}
|
||||
}
|
||||
}
|
||||
219
modules/glass-poster/ios/GlassPosterView.swift
Normal file
219
modules/glass-poster/ios/GlassPosterView.swift
Normal file
@@ -0,0 +1,219 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Wrapper view that observes state changes from GlassPosterState
|
||||
/// This allows SwiftUI to efficiently update only the changed properties
|
||||
/// instead of rebuilding the entire view hierarchy on every prop change.
|
||||
struct GlassPosterViewWrapper: View {
|
||||
@ObservedObject var state: GlassPosterState
|
||||
|
||||
var body: some View {
|
||||
GlassPosterView(
|
||||
imageUrl: state.imageUrl,
|
||||
aspectRatio: state.aspectRatio,
|
||||
cornerRadius: state.cornerRadius,
|
||||
progress: state.progress,
|
||||
showWatchedIndicator: state.showWatchedIndicator,
|
||||
isFocused: state.isFocused,
|
||||
width: state.width
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI view with tvOS 26 Liquid Glass effect
|
||||
struct GlassPosterView: View {
|
||||
var imageUrl: String? = nil
|
||||
var aspectRatio: Double = 10.0 / 15.0
|
||||
var cornerRadius: Double = 24
|
||||
var progress: Double = 0
|
||||
var showWatchedIndicator: Bool = false
|
||||
var isFocused: Bool = false
|
||||
var width: Double = 260
|
||||
|
||||
// Internal focus state for tvOS
|
||||
@FocusState private var isInternallyFocused: Bool
|
||||
|
||||
// Combined focus state (external prop OR internal focus)
|
||||
private var isCurrentlyFocused: Bool {
|
||||
isFocused || isInternallyFocused
|
||||
}
|
||||
|
||||
// Calculated height based on width and aspect ratio
|
||||
private var height: Double {
|
||||
width / aspectRatio
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
if #available(tvOS 26.0, *) {
|
||||
glassContent
|
||||
} else {
|
||||
fallbackContent
|
||||
}
|
||||
#else
|
||||
fallbackContent
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - tvOS 26+ Content (glass effect disabled for now)
|
||||
|
||||
#if os(tvOS)
|
||||
@available(tvOS 26.0, *)
|
||||
private var glassContent: some View {
|
||||
return ZStack {
|
||||
// Image content
|
||||
imageContent
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
|
||||
// White border on focus
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.stroke(Color.white, lineWidth: isCurrentlyFocused ? 4 : 0)
|
||||
|
||||
// Progress bar overlay
|
||||
if progress > 0 {
|
||||
progressOverlay
|
||||
}
|
||||
|
||||
// Watched indicator
|
||||
if showWatchedIndicator {
|
||||
watchedIndicatorOverlay
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
.focusable()
|
||||
.focused($isInternallyFocused)
|
||||
.scaleEffect(isCurrentlyFocused ? 1.05 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: isCurrentlyFocused)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Fallback for older tvOS versions
|
||||
|
||||
private var fallbackContent: some View {
|
||||
ZStack {
|
||||
// Main image
|
||||
imageContent
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
|
||||
// White border on focus
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.stroke(Color.white, lineWidth: isFocused ? 4 : 0)
|
||||
|
||||
// Progress bar overlay
|
||||
if progress > 0 {
|
||||
progressOverlay
|
||||
}
|
||||
|
||||
// Watched indicator
|
||||
if showWatchedIndicator {
|
||||
watchedIndicatorOverlay
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
.scaleEffect(isFocused ? 1.05 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: isFocused)
|
||||
}
|
||||
|
||||
// MARK: - Shared Components
|
||||
|
||||
private var imageContent: some View {
|
||||
Group {
|
||||
if let urlString = imageUrl, let url = URL(string: urlString) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
placeholderView
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: width, height: height)
|
||||
.clipped()
|
||||
case .failure:
|
||||
placeholderView
|
||||
@unknown default:
|
||||
placeholderView
|
||||
}
|
||||
}
|
||||
} else {
|
||||
placeholderView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholderView: some View {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
}
|
||||
|
||||
private var progressOverlay: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
// Background track
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.3))
|
||||
.frame(height: 4)
|
||||
|
||||
// Progress fill
|
||||
Rectangle()
|
||||
.fill(Color.white)
|
||||
.frame(width: geometry.size.width * CGFloat(progress / 100), height: 4)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
private var watchedIndicatorOverlay: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.9))
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#if DEBUG
|
||||
struct GlassPosterView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 20) {
|
||||
GlassPosterView(
|
||||
imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
|
||||
aspectRatio: 10.0 / 15.0,
|
||||
cornerRadius: 24,
|
||||
progress: 45,
|
||||
showWatchedIndicator: false,
|
||||
isFocused: true,
|
||||
width: 260
|
||||
)
|
||||
|
||||
GlassPosterView(
|
||||
imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
|
||||
aspectRatio: 16.0 / 9.0,
|
||||
cornerRadius: 24,
|
||||
progress: 75,
|
||||
showWatchedIndicator: true,
|
||||
isFocused: false,
|
||||
width: 400
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
26
modules/glass-poster/src/GlassPoster.types.ts
Normal file
26
modules/glass-poster/src/GlassPoster.types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
export interface GlassPosterViewProps {
|
||||
/** URL of the image to display */
|
||||
imageUrl: string | null;
|
||||
/** Aspect ratio of the poster (width/height). Default: 10/15 for portrait, 16/9 for landscape */
|
||||
aspectRatio: number;
|
||||
/** Corner radius in points. Default: 24 */
|
||||
cornerRadius: number;
|
||||
/** Progress percentage (0-100). Shows progress bar at bottom when > 0 */
|
||||
progress: number;
|
||||
/** Whether to show the watched checkmark indicator */
|
||||
showWatchedIndicator: boolean;
|
||||
/** Whether the poster is currently focused (for scale animation) */
|
||||
isFocused: boolean;
|
||||
/** Width of the poster in points. Required for proper sizing. */
|
||||
width: number;
|
||||
/** Style for the container view */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Called when the image loads successfully */
|
||||
onLoad?: () => void;
|
||||
/** Called when image loading fails */
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export type GlassPosterModuleEvents = Record<string, never>;
|
||||
43
modules/glass-poster/src/GlassPosterModule.ts
Normal file
43
modules/glass-poster/src/GlassPosterModule.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NativeModule, requireNativeModule } from "expo";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import type { GlassPosterModuleEvents } from "./GlassPoster.types";
|
||||
|
||||
declare class GlassPosterModuleType extends NativeModule<GlassPosterModuleEvents> {
|
||||
isGlassEffectAvailable(): boolean;
|
||||
}
|
||||
|
||||
// Only load the native module on tvOS
|
||||
let GlassPosterNativeModule: GlassPosterModuleType | null = null;
|
||||
|
||||
if (Platform.OS === "ios" && Platform.isTV) {
|
||||
try {
|
||||
GlassPosterNativeModule =
|
||||
requireNativeModule<GlassPosterModuleType>("GlassPoster");
|
||||
} catch {
|
||||
// Module not available, will use fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the native glass effect is available (tvOS 26+)
|
||||
* NOTE: Glass effect is currently disabled for performance reasons.
|
||||
* The native module rebuilds views on every focus change which causes lag.
|
||||
* Re-enable by uncommenting the native module check below.
|
||||
*/
|
||||
export function isGlassEffectAvailable(): boolean {
|
||||
// Glass effect disabled - using JS-based focus effects instead
|
||||
return false;
|
||||
|
||||
// Original implementation (re-enable when glass effect is optimized):
|
||||
// if (!GlassPosterNativeModule) {
|
||||
// return false;
|
||||
// }
|
||||
// try {
|
||||
// return GlassPosterNativeModule.isGlassEffectAvailable();
|
||||
// } catch {
|
||||
// return false;
|
||||
// }
|
||||
}
|
||||
|
||||
export default GlassPosterNativeModule;
|
||||
46
modules/glass-poster/src/GlassPosterView.tsx
Normal file
46
modules/glass-poster/src/GlassPosterView.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { requireNativeView } from "expo";
|
||||
import * as React from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
|
||||
import type { GlassPosterViewProps } from "./GlassPoster.types";
|
||||
import { isGlassEffectAvailable } from "./GlassPosterModule";
|
||||
|
||||
// Only require the native view on tvOS
|
||||
let NativeGlassPosterView: React.ComponentType<GlassPosterViewProps> | null =
|
||||
null;
|
||||
|
||||
if (Platform.OS === "ios" && Platform.isTV) {
|
||||
try {
|
||||
NativeGlassPosterView =
|
||||
requireNativeView<GlassPosterViewProps>("GlassPoster");
|
||||
} catch {
|
||||
// Module not available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GlassPosterView - Native SwiftUI glass effect poster for tvOS 26+
|
||||
*
|
||||
* On tvOS 26+: Renders with native Liquid Glass effect
|
||||
* On older tvOS: Renders with subtle glass-like material effect
|
||||
* On other platforms: Returns null (use existing poster components)
|
||||
*/
|
||||
const GlassPosterView: React.FC<GlassPosterViewProps> = (props) => {
|
||||
// Only render on tvOS
|
||||
if (!Platform.isTV || Platform.OS !== "ios") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use native view if available
|
||||
if (NativeGlassPosterView) {
|
||||
return <NativeGlassPosterView {...props} />;
|
||||
}
|
||||
|
||||
// Fallback: return empty view (caller should handle this)
|
||||
return <View style={props.style} />;
|
||||
};
|
||||
|
||||
export default GlassPosterView;
|
||||
|
||||
// Re-export availability check for convenience
|
||||
export { isGlassEffectAvailable };
|
||||
6
modules/glass-poster/src/index.ts
Normal file
6
modules/glass-poster/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./GlassPoster.types";
|
||||
export {
|
||||
default as GlassPosterModule,
|
||||
isGlassEffectAvailable,
|
||||
} from "./GlassPosterModule";
|
||||
export { default as GlassPosterView } from "./GlassPosterView";
|
||||
@@ -1,17 +1,4 @@
|
||||
import type {
|
||||
ChapterInfo,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
TrackInfo,
|
||||
VideoLoadStartPayload,
|
||||
VideoProgressPayload,
|
||||
VideoStateChangePayload,
|
||||
VlcPlayerSource,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
} from "./VlcPlayer.types";
|
||||
import VlcPlayerView from "./VlcPlayerView";
|
||||
|
||||
// Background Downloader
|
||||
export type {
|
||||
ActiveDownload,
|
||||
DownloadCompleteEvent,
|
||||
@@ -20,23 +7,34 @@ export type {
|
||||
DownloadStartedEvent,
|
||||
StorageLocation,
|
||||
} from "./background-downloader";
|
||||
// Background Downloader
|
||||
export { default as BackgroundDownloader } from "./background-downloader";
|
||||
|
||||
// Component
|
||||
export { VlcPlayerView };
|
||||
|
||||
// Component Types
|
||||
export type { VlcPlayerViewProps, VlcPlayerViewRef };
|
||||
|
||||
// Media Types
|
||||
export type { ChapterInfo, TrackInfo, VlcPlayerSource };
|
||||
|
||||
// Playback Events (alphabetically sorted)
|
||||
// Glass Poster (tvOS 26+)
|
||||
export type { GlassPosterViewProps } from "./glass-poster";
|
||||
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
|
||||
// MPV Player (iOS + Android)
|
||||
export type {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VideoLoadStartPayload,
|
||||
VideoProgressPayload,
|
||||
VideoStateChangePayload,
|
||||
};
|
||||
AudioTrack as MpvAudioTrack,
|
||||
MpvPlayerViewProps,
|
||||
MpvPlayerViewRef,
|
||||
OnErrorEventPayload as MpvOnErrorEventPayload,
|
||||
OnLoadEventPayload as MpvOnLoadEventPayload,
|
||||
OnPlaybackStateChangePayload as MpvOnPlaybackStateChangePayload,
|
||||
OnProgressEventPayload as MpvOnProgressEventPayload,
|
||||
OnTracksReadyEventPayload as MpvOnTracksReadyEventPayload,
|
||||
SubtitleTrack as MpvSubtitleTrack,
|
||||
VideoSource as MpvVideoSource,
|
||||
} from "./mpv-player";
|
||||
export { MpvPlayerView } from "./mpv-player";
|
||||
// Top Shelf cache (tvOS)
|
||||
export type {
|
||||
TopShelfCacheItem,
|
||||
TopShelfCachePayload,
|
||||
TopShelfCacheSection,
|
||||
} from "./top-shelf-cache";
|
||||
export { clearTopShelfCache, writeTopShelfCache } from "./top-shelf-cache";
|
||||
// TV recommendations (Android TV)
|
||||
export {
|
||||
clearTvRecommendations,
|
||||
refreshTvRecommendations,
|
||||
syncTvRecommendations,
|
||||
} from "./tv-recommendations";
|
||||
|
||||
57
modules/mpv-player/android/build.gradle
Normal file
57
modules/mpv-player/android/build.gradle
Normal file
@@ -0,0 +1,57 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'expo.modules.mpvplayer'
|
||||
version = '0.7.6'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
||||
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
||||
// Most of the time, you may like to manage the Android SDK versions yourself.
|
||||
def useManagedAndroidSdkVersions = false
|
||||
if (useManagedAndroidSdkVersions) {
|
||||
useDefaultAndroidSdkVersions()
|
||||
} else {
|
||||
buildscript {
|
||||
// Simple helper that allows the root project to override versions declared by this library.
|
||||
ext.safeExtGet = { prop, fallback ->
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
}
|
||||
project.android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 26)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.mpvplayer"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.7.6"
|
||||
ndk {
|
||||
// Architectures supported by mpv-android
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'
|
||||
}
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
sourceSets {
|
||||
main {
|
||||
jniLibs.srcDirs = ['libs']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// libmpv from Maven Central
|
||||
implementation 'dev.jdtech.mpv:libmpv:1.0.0'
|
||||
}
|
||||
9
modules/mpv-player/android/src/main/AndroidManifest.xml
Normal file
9
modules/mpv-player/android/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Required for network streaming -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- Picture-in-Picture feature -->
|
||||
<uses-feature
|
||||
android:name="android.software.picture_in_picture"
|
||||
android:required="false" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,878 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* MPV renderer that wraps libmpv for video playback.
|
||||
* This mirrors the iOS MPVLayerRenderer implementation.
|
||||
*/
|
||||
class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MPVLayerRenderer"
|
||||
|
||||
// Property observation format types
|
||||
const val MPV_FORMAT_NONE = 0
|
||||
const val MPV_FORMAT_STRING = 1
|
||||
const val MPV_FORMAT_OSD_STRING = 2
|
||||
const val MPV_FORMAT_FLAG = 3
|
||||
const val MPV_FORMAT_INT64 = 4
|
||||
const val MPV_FORMAT_DOUBLE = 5
|
||||
const val MPV_FORMAT_NODE = 6
|
||||
}
|
||||
|
||||
private fun isTvDevice(): Boolean {
|
||||
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
||||
return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
}
|
||||
|
||||
/**
|
||||
* True only on the Android emulator. Its goldfish/ranchu MediaCodec can't bind a
|
||||
* decode output surface (decode opens with surface 0x0): HEVC then fails cleanly and
|
||||
* mpv auto-falls-back to software, but H.264 "opens" deceptively and wedges the core
|
||||
* (no fallback) — black video, then any command (seek/pause) deadlocks the UI thread
|
||||
* → ANR. We force software decoding here.
|
||||
*
|
||||
* Only QEMU/SDK-exclusive signals are checked so a real device can never match — a
|
||||
* false positive would needlessly drop shipping hardware to software decoding. The
|
||||
* emulator reports ro.hardware=goldfish|ranchu, an sdk_* product, or a generic/
|
||||
* emulator build fingerprint, none of which appear on real devices.
|
||||
*/
|
||||
private fun isEmulator(): Boolean {
|
||||
val hardware = Build.HARDWARE.lowercase()
|
||||
if (hardware == "goldfish" || hardware == "ranchu") return true
|
||||
|
||||
val product = Build.PRODUCT
|
||||
if (product == "sdk" || product.startsWith("sdk_")) return true
|
||||
|
||||
val fingerprint = Build.FINGERPRINT
|
||||
return fingerprint.startsWith("generic") ||
|
||||
fingerprint.contains("emulator", ignoreCase = true)
|
||||
}
|
||||
|
||||
interface Delegate {
|
||||
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
|
||||
fun onPauseChanged(isPaused: Boolean)
|
||||
fun onLoadingChanged(isLoading: Boolean)
|
||||
fun onReadyToSeek()
|
||||
fun onTracksReady()
|
||||
fun onError(message: String)
|
||||
fun onVideoDimensionsChanged(width: Int, height: Int)
|
||||
}
|
||||
|
||||
var delegate: Delegate? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private var surface: Surface? = null
|
||||
private var isRunning = false
|
||||
|
||||
// This renderer's own mpv handle. Per-instance (not singleton) — each
|
||||
// player screen gets a fresh mpv handle and drops the reference on stop.
|
||||
// We intentionally do NOT call a destroy() equivalent: libmpv 1.0's
|
||||
// nativeDestroy has an internal use-after-free we can't fix from Kotlin,
|
||||
// so we mirror Findroid and let the JVM GC + native finalization path
|
||||
// reclaim resources. Only one player is alive at a time in this app.
|
||||
private var mpv: MPVLib? = null
|
||||
|
||||
// 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
|
||||
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
|
||||
private var _videoHeight: Int = 0
|
||||
|
||||
val videoWidth: Int
|
||||
get() = _videoWidth
|
||||
|
||||
val videoHeight: Int
|
||||
get() = _videoHeight
|
||||
|
||||
// Current video config
|
||||
private var currentUrl: String? = null
|
||||
private var currentHeaders: Map<String, String>? = null
|
||||
private var pendingExternalSubtitles: List<String> = emptyList()
|
||||
private var initialSubtitleId: Int? = null
|
||||
private var initialAudioId: Int? = null
|
||||
|
||||
val isPausedState: Boolean
|
||||
get() = _isPaused
|
||||
|
||||
val currentPosition: Double
|
||||
get() = cachedPosition
|
||||
|
||||
val duration: Double
|
||||
get() = cachedDuration
|
||||
|
||||
/**
|
||||
* The VO driver to use. Stored so attachSurface can re-enable the same driver.
|
||||
*/
|
||||
private var voDriver: String = "gpu-next"
|
||||
|
||||
fun start(voDriver: String = "gpu-next") {
|
||||
if (isRunning) return
|
||||
|
||||
try {
|
||||
// Per-instance handle — see class-level comment. Each player gets
|
||||
// its own mpv; we drop the reference in stop().
|
||||
val mpv = MPVLib.create(context)
|
||||
this.mpv = mpv
|
||||
mpv.addObserver(this)
|
||||
|
||||
// Resolved once — TV gets the memory-pressure customizations
|
||||
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
|
||||
// audio-buffer) that would be counterproductive on higher-RAM
|
||||
// mobile devices. Demuxer cache sizes are NOT included here —
|
||||
// those come from user settings via load().
|
||||
val isTV = isTvDevice()
|
||||
|
||||
// mpv config directory — used by the config-dir option below and
|
||||
// as XDG_CONFIG_HOME for fontconfig.
|
||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||
if (!mpvDir.exists()) mpvDir.mkdirs()
|
||||
|
||||
// Point fontconfig (new in libmpv 1.0) at writable app dirs so it
|
||||
// persists its font index across runs instead of re-walking
|
||||
// /system/fonts on every subtitle/seek event. Each rebuild costs
|
||||
// ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then
|
||||
// holds onto. Without this we see "No usable fontconfig
|
||||
// configuration file found, using fallback" on every re-init.
|
||||
try {
|
||||
val cacheDir = context.cacheDir.absolutePath
|
||||
val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath
|
||||
Os.setenv("XDG_CACHE_HOME", cacheDir, true)
|
||||
Os.setenv("XDG_CONFIG_HOME", configDir, true)
|
||||
Os.setenv("HOME", configDir, true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}")
|
||||
}
|
||||
|
||||
mpv?.setOptionString("config", "yes")
|
||||
mpv?.setOptionString("config-dir", mpvDir.path)
|
||||
|
||||
// Configure mpv options before initialization (based on Findroid)
|
||||
this.voDriver = voDriver
|
||||
mpv?.setOptionString("vo", voDriver)
|
||||
mpv?.setOptionString("gpu-context", "android")
|
||||
mpv?.setOptionString("opengl-es", "yes")
|
||||
|
||||
// Hardware decoder codecs (shared)
|
||||
mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
||||
|
||||
// Pause on initial cache fill (shared default). The actual
|
||||
// cache mode, cache-secs, and demuxer cache sizes come from
|
||||
// user preferences and are applied per-load in load().
|
||||
mpv?.setOptionString("cache-pause-initial", "yes")
|
||||
|
||||
// Hardware decode path + TV-only memory options. Demuxer cache
|
||||
// sizes and cache-secs are NOT set here — they come from user
|
||||
// preferences via load().
|
||||
// - Emulator: software decode. Its MediaCodec can't bind an
|
||||
// output surface (surface 0x0); HEVC then fails cleanly and
|
||||
// mpv auto-falls-back to software, but H.264 "opens"
|
||||
// deceptively and wedges the core with no fallback (black
|
||||
// video, then any command — seek/pause — deadlocks the UI
|
||||
// thread → ANR). hwdec=no makes every codec render via the
|
||||
// gpu-next VO. Real devices unaffected.
|
||||
// - Real TV hardware: zero-copy `mediacodec` (fastest on
|
||||
// low-power devices) + fast profile.
|
||||
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
||||
when {
|
||||
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
||||
isTV -> {
|
||||
mpv?.setOptionString("hwdec", "mediacodec")
|
||||
mpv?.setOptionString("profile", "fast")
|
||||
// Don't retain already-played content for backward
|
||||
// seeking over a network source — Jellyfin can re-fetch
|
||||
// on demand. Saves up to ~30 MiB on long seeks and
|
||||
// reduces swap pressure.
|
||||
mpv?.setOptionString("demuxer-seekable-cache", "no")
|
||||
// Larger audio buffer to absorb page-fault stalls
|
||||
// (default ~0.2s). Cheap insurance against the audio
|
||||
// underruns that happen when the kernel is swap-thrashing.
|
||||
mpv?.setOptionString("audio-buffer", "0.5")
|
||||
}
|
||||
else -> mpv?.setOptionString("hwdec", "mediacodec-copy")
|
||||
}
|
||||
|
||||
// Seeking optimization - faster seeking at the cost of less precision
|
||||
// Use keyframe seeking by default (much faster for network streams)
|
||||
mpv?.setOptionString("hr-seek", "no")
|
||||
// Drop frames during seeking for faster response
|
||||
mpv?.setOptionString("hr-seek-framedrop", "yes")
|
||||
|
||||
// Subtitle settings
|
||||
mpv?.setOptionString("sub-scale-with-window", "no")
|
||||
mpv?.setOptionString("sub-use-margins", "no")
|
||||
mpv?.setOptionString("subs-match-os-language", "yes")
|
||||
mpv?.setOptionString("subs-fallback", "yes")
|
||||
|
||||
// Important: Start with force-window=no, will be set to yes when surface is attached
|
||||
mpv?.setOptionString("force-window", "no")
|
||||
mpv?.setOptionString("keep-open", "always")
|
||||
|
||||
mpv.initialize()
|
||||
|
||||
// Observe properties
|
||||
observeProperties()
|
||||
|
||||
isRunning = true
|
||||
Log.i(TAG, "MPV renderer started")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start MPV renderer: ${e.message}")
|
||||
delegate?.onError("Failed to start renderer: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!isRunning) return
|
||||
isRunning = false
|
||||
|
||||
val m = mpv
|
||||
mpv = null
|
||||
|
||||
// Clear cached media state on the main thread so the next player
|
||||
// screen doesn't observe stale position/duration values during the
|
||||
// (async) teardown below.
|
||||
currentUrl = null
|
||||
currentHeaders = null
|
||||
pendingExternalSubtitles = emptyList()
|
||||
initialSubtitleId = null
|
||||
initialAudioId = null
|
||||
cachedPosition = 0.0
|
||||
cachedDuration = 0.0
|
||||
cachedCacheSeconds = 0.0
|
||||
|
||||
if (m == null) return
|
||||
|
||||
// Teardown runs on a background daemon thread. mpv's "stop" command
|
||||
// flushes the demuxer queue and releases the MediaCodec hardware
|
||||
// decoder — synchronous JNI work that can block for hundreds of ms
|
||||
// on TV hardware. Running it on the main thread produced a visible
|
||||
// delay/stutter between pressing "exit" and the confirm alert
|
||||
// appearing. The local `m` keeps the MPVLib instance alive for the
|
||||
// lifetime of this thread even though we've already nulled `mpv`.
|
||||
Thread {
|
||||
// Drop force-window BEFORE issuing stop. With keep-open=always +
|
||||
// force-window=yes, mpv tears down the decoder at stop time but
|
||||
// tries to keep the VO alive — which fires an internal
|
||||
// video-reconfig. On libmpv 1.0's gpu-next/android backend that
|
||||
// reconfig path crashes with "Missing surface pointer" because we
|
||||
// detach the Surface below before mpv's worker reaches the
|
||||
// reconfig step (command() is async). Setting force-window=no
|
||||
// first makes mpv tear VO down cleanly instead of attempting a
|
||||
// doomed re-init, eliminating the fatal VO error and the
|
||||
// "playback won't restart" aftermath.
|
||||
try {
|
||||
m.setOptionString("force-window", "no")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing force-window: ${e.message}")
|
||||
}
|
||||
try {
|
||||
// Stop playback — flushes demuxer queue and signals MediaCodec
|
||||
// to release its hardware decoders. This is the bulk of what
|
||||
// we can reclaim without calling destroy().
|
||||
m.command(arrayOf("stop"))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping mpv playback: ${e.message}")
|
||||
}
|
||||
try {
|
||||
m.removeObserver(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing mpv observer: ${e.message}")
|
||||
}
|
||||
try {
|
||||
m.detachSurface()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error detaching mpv surface: ${e.message}")
|
||||
}
|
||||
}.also { it.isDaemon = true }.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach surface and ensure video output is active.
|
||||
*
|
||||
* During PiP transitions, the surface is destroyed and recreated by Android.
|
||||
* We keep the VO pipeline alive (not killed with vo=null) so that rendering
|
||||
* resumes immediately when the new surface is attached — avoiding the black
|
||||
* screen that occurs when the VO is fully re-initialized via setOptionString.
|
||||
*/
|
||||
fun attachSurface(surface: Surface) {
|
||||
this.surface = surface
|
||||
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||
if (isRunning) {
|
||||
mpv?.attachSurface(surface)
|
||||
mpv?.setOptionString("force-window", "yes")
|
||||
// Read back vo to confirm it's still active
|
||||
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach surface without killing the VO pipeline.
|
||||
*
|
||||
* The previous approach (vo=null / force-window=no) destroyed the entire video
|
||||
* output pipeline on every surface transition. During PiP mode, the rapid
|
||||
* destroy/recreate cycle caused a black screen because setOptionString("vo", ...)
|
||||
* did not properly re-initialize rendering into the new PiP surface.
|
||||
*
|
||||
* By keeping the VO alive, frames are simply dropped while no surface is
|
||||
* attached, and rendering resumes immediately when the new surface arrives.
|
||||
*/
|
||||
fun detachSurface() {
|
||||
this.surface = null
|
||||
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||
if (isRunning) {
|
||||
mpv?.detachSurface()
|
||||
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the surface size. Called from surfaceChanged.
|
||||
* Based on Findroid's implementation.
|
||||
*/
|
||||
fun updateSurfaceSize(width: Int, height: Int) {
|
||||
if (isRunning) {
|
||||
mpv?.setPropertyString("android-surface-size", "${width}x$height")
|
||||
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
||||
} else {
|
||||
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force mpv to render a frame to the current surface.
|
||||
* Steps forward one frame then seeks back to the original position.
|
||||
* Used after PiP entry to work around mpv stopping pixel output.
|
||||
*/
|
||||
fun forceRedraw() {
|
||||
if (!isRunning) return
|
||||
val pos = cachedPosition
|
||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||
mpv?.command(arrayOf("frame-step"))
|
||||
if (pos > 0) {
|
||||
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||
}
|
||||
}
|
||||
|
||||
fun load(
|
||||
url: String,
|
||||
headers: Map<String, String>? = null,
|
||||
startPosition: Double? = null,
|
||||
externalSubtitles: List<String>? = null,
|
||||
initialSubtitleId: Int? = null,
|
||||
initialAudioId: Int? = null,
|
||||
cacheEnabled: String? = null,
|
||||
cacheSeconds: Int? = null,
|
||||
demuxerMaxBytes: Int? = null,
|
||||
demuxerMaxBackBytes: Int? = null
|
||||
) {
|
||||
currentUrl = url
|
||||
currentHeaders = headers
|
||||
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
||||
this.initialSubtitleId = initialSubtitleId
|
||||
this.initialAudioId = initialAudioId
|
||||
|
||||
_isLoading = true
|
||||
isReadyToSeek = false
|
||||
mainHandler.post { delegate?.onLoadingChanged(true) }
|
||||
|
||||
// Stop previous playback
|
||||
mpv?.command(arrayOf("stop"))
|
||||
|
||||
// Set HTTP headers if provided
|
||||
updateHttpHeaders(headers)
|
||||
|
||||
// Apply cache/buffer settings from user preferences (mirrors iOS).
|
||||
// These override the conservative defaults applied in start() so the
|
||||
// TV/mobile settings screen actually takes effect on Android.
|
||||
cacheEnabled?.let { mpv?.setOptionString("cache", it) }
|
||||
cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) }
|
||||
demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") }
|
||||
demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") }
|
||||
|
||||
// Set start position. mpv's time parser requires '.' as the decimal
|
||||
// separator; use Locale.US so devices with other default locales
|
||||
// (e.g. ',' as decimal separator) don't break resume-from-position.
|
||||
if (startPosition != null && startPosition > 0) {
|
||||
mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition))
|
||||
} else {
|
||||
mpv?.setPropertyString("start", "0")
|
||||
}
|
||||
|
||||
// Set initial audio track if specified
|
||||
if (initialAudioId != null && initialAudioId > 0) {
|
||||
setAudioTrack(initialAudioId)
|
||||
}
|
||||
|
||||
// Set initial subtitle track if no external subs
|
||||
if (pendingExternalSubtitles.isEmpty()) {
|
||||
if (initialSubtitleId != null) {
|
||||
setSubtitleTrack(initialSubtitleId)
|
||||
} else {
|
||||
disableSubtitles()
|
||||
}
|
||||
} else {
|
||||
disableSubtitles()
|
||||
}
|
||||
|
||||
// Load the file
|
||||
mpv?.command(arrayOf("loadfile", url, "replace"))
|
||||
}
|
||||
|
||||
fun reloadCurrentItem() {
|
||||
currentUrl?.let { url ->
|
||||
load(url, currentHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateHttpHeaders(headers: Map<String, String>?) {
|
||||
if (headers.isNullOrEmpty()) {
|
||||
// Clear headers
|
||||
return
|
||||
}
|
||||
|
||||
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
||||
mpv?.setPropertyString("http-header-fields", headerString)
|
||||
}
|
||||
|
||||
private fun observeProperties() {
|
||||
mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||
mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||
mpv?.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||
mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||
mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||
// Video dimensions for PiP aspect ratio
|
||||
mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||
mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||
}
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
fun play() {
|
||||
mpv?.setPropertyBoolean("pause", false)
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
mpv?.setPropertyBoolean("pause", true)
|
||||
}
|
||||
|
||||
fun togglePause() {
|
||||
if (_isPaused) play() else pause()
|
||||
}
|
||||
|
||||
fun seekTo(seconds: Double) {
|
||||
val clamped = maxOf(0.0, seconds)
|
||||
cachedPosition = clamped
|
||||
mpv?.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||
}
|
||||
|
||||
fun seekBy(seconds: Double) {
|
||||
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
||||
cachedPosition = newPosition
|
||||
mpv?.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||
}
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
_playbackSpeed = speed
|
||||
mpv?.setPropertyDouble("speed", speed)
|
||||
}
|
||||
|
||||
fun getSpeed(): Double {
|
||||
return mpv?.getPropertyDouble("speed") ?: _playbackSpeed
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
|
||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||
val tracks = mutableListOf<Map<String, Any>>()
|
||||
|
||||
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
||||
|
||||
for (i in 0 until trackCount) {
|
||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
||||
if (trackType != "sub") continue
|
||||
|
||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||
|
||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
|
||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
track["selected"] = selected
|
||||
|
||||
tracks.add(track)
|
||||
}
|
||||
|
||||
return tracks
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
||||
if (trackId < 0) {
|
||||
mpv?.setPropertyString("sid", "no")
|
||||
} else {
|
||||
mpv?.setPropertyInt("sid", trackId)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableSubtitles() {
|
||||
mpv?.setPropertyString("sid", "no")
|
||||
}
|
||||
|
||||
fun getCurrentSubtitleTrack(): Int {
|
||||
return mpv?.getPropertyInt("sid") ?: 0
|
||||
}
|
||||
|
||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||
val flag = if (select) "select" else "cached"
|
||||
mpv?.command(arrayOf("sub-add", url, flag))
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
fun setSubtitlePosition(position: Int) {
|
||||
mpv?.setPropertyInt("sub-pos", position)
|
||||
}
|
||||
|
||||
fun setSubtitleScale(scale: Double) {
|
||||
mpv?.setPropertyDouble("sub-scale", scale)
|
||||
}
|
||||
|
||||
fun setSubtitleMarginY(margin: Int) {
|
||||
mpv?.setPropertyInt("sub-margin-y", margin)
|
||||
}
|
||||
|
||||
fun setSubtitleAlignX(alignment: String) {
|
||||
mpv?.setPropertyString("sub-align-x", alignment)
|
||||
}
|
||||
|
||||
fun setSubtitleAlignY(alignment: String) {
|
||||
mpv?.setPropertyString("sub-align-y", alignment)
|
||||
}
|
||||
|
||||
fun setSubtitleFontSize(size: Int) {
|
||||
mpv?.setPropertyInt("sub-font-size", size)
|
||||
}
|
||||
|
||||
fun setSubtitleBorderStyle(style: String) {
|
||||
mpv?.setPropertyString("sub-border-style", style)
|
||||
}
|
||||
|
||||
fun setSubtitleBackgroundColor(color: String) {
|
||||
mpv?.setPropertyString("sub-back-color", color)
|
||||
}
|
||||
|
||||
fun setSubtitleAssOverride(mode: String) {
|
||||
mpv?.setPropertyString("sub-ass-override", mode)
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
|
||||
fun getAudioTracks(): List<Map<String, Any>> {
|
||||
val tracks = mutableListOf<Map<String, Any>>()
|
||||
|
||||
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
||||
|
||||
for (i in 0 until trackCount) {
|
||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
||||
if (trackType != "audio") continue
|
||||
|
||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||
|
||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||
|
||||
val channels = mpv?.getPropertyInt("track-list/$i/audio-channels")
|
||||
if (channels != null && channels > 0) {
|
||||
track["channels"] = channels
|
||||
}
|
||||
|
||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
track["selected"] = selected
|
||||
|
||||
tracks.add(track)
|
||||
}
|
||||
|
||||
return tracks
|
||||
}
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
||||
mpv?.setPropertyInt("aid", trackId)
|
||||
}
|
||||
|
||||
fun getCurrentAudioTrack(): Int {
|
||||
return mpv?.getPropertyInt("aid") ?: 0
|
||||
}
|
||||
|
||||
// MARK: - Video Scaling
|
||||
|
||||
fun setZoomedToFill(zoomed: Boolean) {
|
||||
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
||||
val panscanValue = if (zoomed) 1.0 else 0.0
|
||||
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
||||
mpv?.setPropertyDouble("panscan", panscanValue)
|
||||
}
|
||||
|
||||
// MARK: - Technical Info
|
||||
|
||||
fun getTechnicalInfo(): Map<String, Any> {
|
||||
val info = mutableMapOf<String, Any>()
|
||||
|
||||
// Video dimensions
|
||||
mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||
info["videoWidth"] = it
|
||||
}
|
||||
mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||
info["videoHeight"] = it
|
||||
}
|
||||
|
||||
// Video codec
|
||||
mpv?.getPropertyString("video-format")?.let {
|
||||
info["videoCodec"] = it
|
||||
}
|
||||
|
||||
// Audio codec
|
||||
mpv?.getPropertyString("audio-codec-name")?.let {
|
||||
info["audioCodec"] = it
|
||||
}
|
||||
|
||||
// FPS (container fps)
|
||||
mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||
info["fps"] = it
|
||||
}
|
||||
|
||||
// Video bitrate (bits per second)
|
||||
mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||
info["videoBitrate"] = it
|
||||
}
|
||||
|
||||
// Audio bitrate (bits per second)
|
||||
mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||
info["audioBitrate"] = it
|
||||
}
|
||||
|
||||
// Demuxer cache duration (seconds of video buffered)
|
||||
mpv?.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||
info["cacheSeconds"] = it
|
||||
}
|
||||
|
||||
// Configured cache limits — read back from mpv to confirm user
|
||||
// settings actually took effect. mpv stores byte sizes as int64
|
||||
// (bytes); convert to MiB for display.
|
||||
mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes ->
|
||||
info["demuxerMaxBytes"] = bytes / (1024 * 1024)
|
||||
}
|
||||
mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes ->
|
||||
info["demuxerMaxBackBytes"] = bytes / (1024 * 1024)
|
||||
}
|
||||
mpv?.getPropertyDouble("cache-secs")?.let { secs ->
|
||||
info["cacheSecsLimit"] = secs
|
||||
}
|
||||
|
||||
// Dropped frames
|
||||
mpv?.getPropertyInt("frame-drop-count")?.let {
|
||||
info["droppedFrames"] = it
|
||||
}
|
||||
|
||||
// Active video output driver (read from MPV to confirm what's actually applied)
|
||||
mpv?.getPropertyString("vo")?.let {
|
||||
info["voDriver"] = it
|
||||
}
|
||||
|
||||
// Active hardware decoder.
|
||||
// hwdec-current yields e.g. "mediacodec",
|
||||
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
|
||||
mpv?.getPropertyString("hwdec-current")?.let {
|
||||
info["hwdec"] = it
|
||||
}
|
||||
|
||||
// Estimated video output fps (renderer-side, after filtering).
|
||||
// Useful for diagnosing display/pipeline drops vs container fps.
|
||||
mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let {
|
||||
info["estimatedVfFps"] = it
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// MARK: - MPVLib.EventObserver
|
||||
|
||||
override fun eventProperty(property: String) {
|
||||
// Property changed but no value provided
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Long) {
|
||||
when (property) {
|
||||
"track-list/count" -> {
|
||||
if (value > 0) {
|
||||
Log.i(TAG, "Track list updated: $value tracks available")
|
||||
mainHandler.post { delegate?.onTracksReady() }
|
||||
}
|
||||
}
|
||||
"video-params/w" -> {
|
||||
val width = value.toInt()
|
||||
if (width > 0 && width != _videoWidth) {
|
||||
_videoWidth = width
|
||||
notifyVideoDimensionsIfReady()
|
||||
}
|
||||
}
|
||||
"video-params/h" -> {
|
||||
val height = value.toInt()
|
||||
if (height > 0 && height != _videoHeight) {
|
||||
_videoHeight = height
|
||||
notifyVideoDimensionsIfReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyVideoDimensionsIfReady() {
|
||||
if (_videoWidth > 0 && _videoHeight > 0) {
|
||||
Log.i(TAG, "Video dimensions: ${_videoWidth}x${_videoHeight}")
|
||||
mainHandler.post { delegate?.onVideoDimensionsChanged(_videoWidth, _videoHeight) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Boolean) {
|
||||
when (property) {
|
||||
"pause" -> {
|
||||
if (value != _isPaused) {
|
||||
_isPaused = value
|
||||
mainHandler.post { delegate?.onPauseChanged(value) }
|
||||
}
|
||||
}
|
||||
"paused-for-cache" -> {
|
||||
if (value != _isLoading) {
|
||||
_isLoading = value
|
||||
mainHandler.post { delegate?.onLoadingChanged(value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: String) {
|
||||
// Handle string properties if needed
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Double) {
|
||||
when (property) {
|
||||
"duration" -> {
|
||||
cachedDuration = value
|
||||
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
|
||||
}
|
||||
"time-pos" -> {
|
||||
cachedPosition = value
|
||||
// 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, cachedCacheSeconds) }
|
||||
}
|
||||
}
|
||||
"demuxer-cache-duration" -> {
|
||||
cachedCacheSeconds = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun event(eventId: Int) {
|
||||
when (eventId) {
|
||||
MPVLib.MPV_EVENT_FILE_LOADED -> {
|
||||
// Add external subtitles now that file is loaded
|
||||
if (pendingExternalSubtitles.isNotEmpty()) {
|
||||
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
||||
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
||||
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
|
||||
mpv?.command(arrayOf("sub-add", subUrl, "auto"))
|
||||
}
|
||||
pendingExternalSubtitles = emptyList()
|
||||
}
|
||||
|
||||
// Apply the initial audio/subtitle selection now that the file's
|
||||
// tracks are enumerated. Setting sid/aid before `loadfile` does not
|
||||
// reliably stick for embedded tracks (the selection is silently
|
||||
// dropped), so we (re)apply here for embedded and external alike.
|
||||
// This is what makes a carried-over subtitle show up on the next
|
||||
// episode without a manual re-selection.
|
||||
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
|
||||
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
||||
|
||||
if (!isReadyToSeek) {
|
||||
isReadyToSeek = true
|
||||
mainHandler.post { delegate?.onReadyToSeek() }
|
||||
}
|
||||
|
||||
if (_isLoading) {
|
||||
_isLoading = false
|
||||
mainHandler.post { delegate?.onLoadingChanged(false) }
|
||||
}
|
||||
}
|
||||
MPVLib.MPV_EVENT_SEEK -> {
|
||||
// Seek started - show loading indicator and enable immediate progress updates
|
||||
_isSeeking = true
|
||||
if (!_isLoading) {
|
||||
_isLoading = true
|
||||
mainHandler.post { delegate?.onLoadingChanged(true) }
|
||||
}
|
||||
}
|
||||
MPVLib.MPV_EVENT_PLAYBACK_RESTART -> {
|
||||
// Video playback has started/restarted (including after seek)
|
||||
_isSeeking = false
|
||||
if (_isLoading) {
|
||||
_isLoading = false
|
||||
mainHandler.post { delegate?.onLoadingChanged(false) }
|
||||
}
|
||||
}
|
||||
MPVLib.MPV_EVENT_END_FILE -> {
|
||||
Log.i(TAG, "Playback ended")
|
||||
}
|
||||
MPVLib.MPV_EVENT_SHUTDOWN -> {
|
||||
Log.w(TAG, "MPV shutdown")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import dev.jdtech.mpv.MPVLib as LibMPV
|
||||
|
||||
/**
|
||||
* Per-instance wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||
*
|
||||
* libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns
|
||||
* a fresh, independent handle. Each player creates its own MPVLib instance
|
||||
* (Findroid pattern) and on teardown we simply drop the reference. We do NOT
|
||||
* call `LibMPV.destroy()` — its native implementation has an internal
|
||||
* use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the
|
||||
* GC reach the JVM-level finalizer (or never reaching it, since the native
|
||||
* handle lives in process-global state until exit) is strictly safer than
|
||||
* crashing.
|
||||
*
|
||||
* Trade-off: mpv's native footprint (decoder + demuxer cache) for one player
|
||||
* stays allocated until the next player's allocation displaces it in scudo's
|
||||
* arena. On a TV app where the player is the dominant memory consumer and
|
||||
* only one player is alive at a time, this is acceptable.
|
||||
*/
|
||||
class MPVLib private constructor(private val instance: LibMPV) {
|
||||
|
||||
// Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver
|
||||
// so MPVLayerRenderer implements a stable, wrapper-owned signature.
|
||||
interface EventObserver {
|
||||
fun eventProperty(property: String)
|
||||
fun eventProperty(property: String, value: Long)
|
||||
fun eventProperty(property: String, value: Boolean)
|
||||
fun eventProperty(property: String, value: String)
|
||||
fun eventProperty(property: String, value: Double)
|
||||
fun event(eventId: Int)
|
||||
}
|
||||
|
||||
private val observers = mutableListOf<EventObserver>()
|
||||
|
||||
// Library event observer that forwards LibMPV callbacks to our observers.
|
||||
private val libObserver = object : LibMPV.EventObserver {
|
||||
override fun eventProperty(property: String) =
|
||||
dispatch { it.eventProperty(property) }
|
||||
|
||||
override fun eventProperty(property: String, value: Long) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: Boolean) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: String) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: Double) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun event(eventId: Int) =
|
||||
dispatch { it.event(eventId) }
|
||||
|
||||
private inline fun dispatch(block: (EventObserver) -> Unit) {
|
||||
synchronized(observers) {
|
||||
observers.forEach(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addObserver(observer: EventObserver) {
|
||||
synchronized(observers) { observers.add(observer) }
|
||||
}
|
||||
|
||||
fun removeObserver(observer: EventObserver) {
|
||||
synchronized(observers) { observers.remove(observer) }
|
||||
}
|
||||
|
||||
fun initialize() {
|
||||
instance.init()
|
||||
}
|
||||
|
||||
fun attachSurface(surface: android.view.Surface) {
|
||||
instance.attachSurface(surface)
|
||||
}
|
||||
|
||||
fun detachSurface() {
|
||||
instance.detachSurface()
|
||||
}
|
||||
|
||||
fun command(cmd: Array<String>) {
|
||||
instance.command(cmd)
|
||||
}
|
||||
|
||||
fun setOptionString(name: String, value: String): Int {
|
||||
return instance.setOptionString(name, value)
|
||||
}
|
||||
|
||||
fun getPropertyInt(name: String): Int? = try {
|
||||
instance.getPropertyInt(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun getPropertyDouble(name: String): Double? = try {
|
||||
instance.getPropertyDouble(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun getPropertyBoolean(name: String): Boolean? = try {
|
||||
instance.getPropertyBoolean(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun getPropertyString(name: String): String? = try {
|
||||
instance.getPropertyString(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun setPropertyInt(name: String, value: Int) {
|
||||
instance.setPropertyInt(name, value)
|
||||
}
|
||||
|
||||
fun setPropertyDouble(name: String, value: Double) {
|
||||
instance.setPropertyDouble(name, value)
|
||||
}
|
||||
|
||||
fun setPropertyBoolean(name: String, value: Boolean) {
|
||||
instance.setPropertyBoolean(name, value)
|
||||
}
|
||||
|
||||
fun setPropertyString(name: String, value: String) {
|
||||
instance.setPropertyString(name, value)
|
||||
}
|
||||
|
||||
fun observeProperty(name: String, format: Int) {
|
||||
instance.observeProperty(name, format)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a fresh mpv handle. Each call returns an independent instance —
|
||||
* do not share across players. Attach exactly one [EventObserver] per
|
||||
* player via [addObserver].
|
||||
*/
|
||||
fun create(context: Context): MPVLib {
|
||||
val lib = LibMPV.create(context)
|
||||
?: throw IllegalStateException("LibMPV.create returned null")
|
||||
val wrapper = MPVLib(lib)
|
||||
// The libObserver is attached for the lifetime of this MPVLib
|
||||
// instance and forwards every LibMPV callback to our observers
|
||||
// list. Player-specific observers are added/removed via
|
||||
// addObserver/removeObserver.
|
||||
lib.addObserver(wrapper.libObserver)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
// MPV Event IDs (kept here so observers can reference them without
|
||||
// holding a reference to an instance).
|
||||
const val MPV_EVENT_NONE = 0
|
||||
const val MPV_EVENT_SHUTDOWN = 1
|
||||
const val MPV_EVENT_LOG_MESSAGE = 2
|
||||
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
||||
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
||||
const val MPV_EVENT_COMMAND_REPLY = 5
|
||||
const val MPV_EVENT_START_FILE = 6
|
||||
const val MPV_EVENT_END_FILE = 7
|
||||
const val MPV_EVENT_FILE_LOADED = 8
|
||||
const val MPV_EVENT_IDLE = 11
|
||||
const val MPV_EVENT_TICK = 14
|
||||
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
||||
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
||||
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
||||
const val MPV_EVENT_SEEK = 20
|
||||
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
||||
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
||||
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
||||
|
||||
// End file reason
|
||||
const val MPV_END_FILE_REASON_EOF = 0
|
||||
const val MPV_END_FILE_REASON_STOP = 2
|
||||
const val MPV_END_FILE_REASON_QUIT = 3
|
||||
const val MPV_END_FILE_REASON_ERROR = 4
|
||||
const val MPV_END_FILE_REASON_REDIRECT = 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class MpvPlayerModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("MpvPlayer")
|
||||
|
||||
// Defines event names that the module can send to JavaScript.
|
||||
Events("onChange")
|
||||
|
||||
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
|
||||
Function("hello") {
|
||||
"Hello from MPV Player! 👋"
|
||||
}
|
||||
|
||||
// Defines a JavaScript function that always returns a Promise and whose native code
|
||||
// is by default dispatched on the different thread than the JavaScript runtime runs on.
|
||||
AsyncFunction("setValueAsync") { value: String ->
|
||||
sendEvent("onChange", mapOf("value" to value))
|
||||
}
|
||||
|
||||
// Enables the module to be used as a native view.
|
||||
View(MpvPlayerView::class) {
|
||||
// All video load options are passed via a single "source" prop
|
||||
Prop("source") { view: MpvPlayerView, source: Map<String, Any?>? ->
|
||||
if (source == null) return@Prop
|
||||
|
||||
val urlString = source["url"] as? String ?: return@Prop
|
||||
|
||||
// Parse cache config if provided (mirrors iOS)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val config = VideoLoadConfig(
|
||||
url = urlString,
|
||||
headers = source["headers"] as? Map<String, String>,
|
||||
externalSubtitles = source["externalSubtitles"] as? List<String>,
|
||||
startPosition = (source["startPosition"] as? Number)?.toDouble(),
|
||||
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||
voDriver = source["voDriver"] as? String,
|
||||
cacheEnabled = cacheConfig?.get("enabled") as? String,
|
||||
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
|
||||
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
|
||||
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// Async function to pause video
|
||||
AsyncFunction("pause") { view: MpvPlayerView ->
|
||||
view.pause()
|
||||
}
|
||||
|
||||
// Stop playback and release the MediaCodec decoder + demuxer.
|
||||
// Does not synchronously tear down the native mpv handle (see
|
||||
// MPVLib / MpvPlayerView.destroy docs). Call before navigating
|
||||
// away from the player screen to avoid OOM during screen
|
||||
// transitions on low-RAM devices.
|
||||
AsyncFunction("destroy") { view: MpvPlayerView ->
|
||||
view.destroy()
|
||||
}
|
||||
|
||||
// Async function to seek to position
|
||||
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
||||
view.seekTo(position)
|
||||
}
|
||||
|
||||
// Async function to seek by offset
|
||||
AsyncFunction("seekBy") { view: MpvPlayerView, offset: Double ->
|
||||
view.seekBy(offset)
|
||||
}
|
||||
|
||||
// Async function to set playback speed
|
||||
AsyncFunction("setSpeed") { view: MpvPlayerView, speed: Double ->
|
||||
view.setSpeed(speed)
|
||||
}
|
||||
|
||||
// Function to get current speed
|
||||
AsyncFunction("getSpeed") { view: MpvPlayerView ->
|
||||
view.getSpeed()
|
||||
}
|
||||
|
||||
// Function to check if paused
|
||||
AsyncFunction("isPaused") { view: MpvPlayerView ->
|
||||
view.isPaused()
|
||||
}
|
||||
|
||||
// Function to get current position
|
||||
AsyncFunction("getCurrentPosition") { view: MpvPlayerView ->
|
||||
view.getCurrentPosition()
|
||||
}
|
||||
|
||||
// Function to get duration
|
||||
AsyncFunction("getDuration") { view: MpvPlayerView ->
|
||||
view.getDuration()
|
||||
}
|
||||
|
||||
// Picture in Picture functions
|
||||
AsyncFunction("startPictureInPicture") { view: MpvPlayerView ->
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("stopPictureInPicture") { view: MpvPlayerView ->
|
||||
view.stopPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureSupported") { view: MpvPlayerView ->
|
||||
view.isPictureInPictureSupported()
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureActive") { view: MpvPlayerView ->
|
||||
view.isPictureInPictureActive()
|
||||
}
|
||||
|
||||
// Subtitle functions
|
||||
AsyncFunction("getSubtitleTracks") { view: MpvPlayerView ->
|
||||
view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { view: MpvPlayerView, trackId: Int ->
|
||||
view.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("disableSubtitles") { view: MpvPlayerView ->
|
||||
view.disableSubtitles()
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentSubtitleTrack") { view: MpvPlayerView ->
|
||||
view.getCurrentSubtitleTrack()
|
||||
}
|
||||
|
||||
AsyncFunction("addSubtitleFile") { view: MpvPlayerView, url: String, select: Boolean ->
|
||||
view.addSubtitleFile(url, select)
|
||||
}
|
||||
|
||||
// Subtitle positioning functions
|
||||
AsyncFunction("setSubtitlePosition") { view: MpvPlayerView, position: Int ->
|
||||
view.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleScale") { view: MpvPlayerView, scale: Double ->
|
||||
view.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleMarginY") { view: MpvPlayerView, margin: Int ->
|
||||
view.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignX") { view: MpvPlayerView, alignment: String ->
|
||||
view.setSubtitleAlignX(alignment)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignY") { view: MpvPlayerView, alignment: String ->
|
||||
view.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleFontSize") { view: MpvPlayerView, size: Int ->
|
||||
view.setSubtitleFontSize(size)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleBorderStyle") { view: MpvPlayerView, style: String ->
|
||||
view.setSubtitleBorderStyle(style)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleBackgroundColor") { view: MpvPlayerView, color: String ->
|
||||
view.setSubtitleBackgroundColor(color)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAssOverride") { view: MpvPlayerView, mode: String ->
|
||||
view.setSubtitleAssOverride(mode)
|
||||
}
|
||||
|
||||
// Audio track functions
|
||||
AsyncFunction("getAudioTracks") { view: MpvPlayerView ->
|
||||
view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { view: MpvPlayerView, trackId: Int ->
|
||||
view.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentAudioTrack") { view: MpvPlayerView ->
|
||||
view.getCurrentAudioTrack()
|
||||
}
|
||||
|
||||
// Video scaling functions
|
||||
AsyncFunction("setZoomedToFill") { view: MpvPlayerView, zoomed: Boolean ->
|
||||
view.setZoomedToFill(zoomed)
|
||||
}
|
||||
|
||||
AsyncFunction("isZoomedToFill") { view: MpvPlayerView ->
|
||||
view.isZoomedToFill()
|
||||
}
|
||||
|
||||
// Technical info function
|
||||
AsyncFunction("getTechnicalInfo") { view: MpvPlayerView ->
|
||||
view.getTechnicalInfo()
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
/**
|
||||
* Configuration for loading a video
|
||||
*/
|
||||
data class VideoLoadConfig(
|
||||
val url: String,
|
||||
val headers: Map<String, String>? = null,
|
||||
val externalSubtitles: List<String>? = null,
|
||||
val startPosition: Double? = null,
|
||||
val autoplay: Boolean = true,
|
||||
val initialSubtitleId: Int? = null,
|
||||
val initialAudioId: Int? = null,
|
||||
val voDriver: String? = null,
|
||||
val cacheEnabled: String? = null,
|
||||
val cacheSeconds: Int? = null,
|
||||
val demuxerMaxBytes: Int? = null,
|
||||
val demuxerMaxBackBytes: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||
*
|
||||
* Uses SurfaceView (not TextureView) so the surface routes directly to
|
||||
* SurfaceFlinger (the OS compositor) rather than compositing into the
|
||||
* app's window surface. This matches mpv-android's architecture and
|
||||
* gives mpv a standalone surface.
|
||||
*
|
||||
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
|
||||
* recreated on PiP entry/exit, and the new surface's initial dimensions
|
||||
* can be stale until the next layout pass. We push dimension updates to
|
||||
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
|
||||
* OnLayoutChangeListener, so the PiP transition (which fires layout
|
||||
* passes on the view itself) reaches mpv promptly.
|
||||
*/
|
||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerView"
|
||||
}
|
||||
|
||||
// Event dispatchers
|
||||
val onLoad by EventDispatcher()
|
||||
val onPlaybackStateChange by EventDispatcher()
|
||||
val onProgress by EventDispatcher()
|
||||
val onError by EventDispatcher()
|
||||
val onTracksReady by EventDispatcher()
|
||||
val onPictureInPictureChange by EventDispatcher()
|
||||
|
||||
private var surfaceView: SurfaceView
|
||||
private var renderer: MPVLayerRenderer? = null
|
||||
private var pipController: PiPController? = null
|
||||
|
||||
private var currentUrl: String? = null
|
||||
private var cachedPosition: Double = 0.0
|
||||
private var cachedDuration: Double = 0.0
|
||||
private var intendedPlayState: Boolean = false
|
||||
private var surfaceReady: Boolean = false
|
||||
private var pendingConfig: VideoLoadConfig? = null
|
||||
private var rendererStarted: Boolean = false
|
||||
private var activeSurface: Surface? = null
|
||||
|
||||
// PiP state tracking
|
||||
private val pipHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
init {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
|
||||
// SurfaceView for video rendering. Routes the surface directly to
|
||||
// SurfaceFlinger (the OS compositor), giving mpv a standalone
|
||||
// surface. TextureView composites into the app's window surface
|
||||
// which is less efficient and breaks PiP transitions.
|
||||
surfaceView = SurfaceView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
surfaceView.holder.addCallback(this@MpvPlayerView)
|
||||
addView(surfaceView)
|
||||
|
||||
// Push dimension updates to mpv on every view bounds change. This
|
||||
// is the primary PiP black-screen fix: entering PiP fires a layout
|
||||
// pass on the SurfaceView itself, and we proactively tell mpv the
|
||||
// new size so it resizes its EGL swapchain before rendering.
|
||||
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
|
||||
oldLeft, oldTop, oldRight, oldBottom ->
|
||||
val w = right - left
|
||||
val h = bottom - top
|
||||
val oldW = oldRight - oldLeft
|
||||
val oldH = oldBottom - oldTop
|
||||
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
|
||||
renderer?.updateSurfaceSize(w, h)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||
pipController = PiPController(context, appContext)
|
||||
pipController?.setPlayerView(surfaceView)
|
||||
pipController?.delegate = object : PiPController.Delegate {
|
||||
override fun onPlay() {
|
||||
play()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
pause()
|
||||
}
|
||||
|
||||
override fun onSeekBy(seconds: Double) {
|
||||
seekBy(seconds)
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
||||
if (isInPiP) {
|
||||
// Post size syncs after the PiP layout settles. Two passes
|
||||
// catch both the immediate surface re-attach and the
|
||||
// post-animation layout pass. Replaces the old TextureView
|
||||
// measure/layout polling hack (forcePiPBufferSize).
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
|
||||
} else {
|
||||
// Restore from PiP: surface resized back to fullscreen.
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
||||
}
|
||||
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
||||
renderer = MPVLayerRenderer(context)
|
||||
renderer?.delegate = this
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the renderer with the given VO driver.
|
||||
* Called lazily on first loadVideo so user settings are available.
|
||||
*/
|
||||
private fun ensureRendererStarted(voDriver: String?) {
|
||||
if (rendererStarted) return
|
||||
|
||||
try {
|
||||
renderer?.start(voDriver ?: "gpu-next")
|
||||
rendererStarted = true
|
||||
|
||||
// If the surface is already alive (surfaceCreated fired before
|
||||
// loadVideo), attach it now. With SurfaceView the surface is
|
||||
// owned by the holder, so we read it from there directly rather
|
||||
// than stashing it on the side.
|
||||
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
|
||||
activeSurface = surface
|
||||
renderer?.attachSurface(surface)
|
||||
syncSurfaceSizeToView()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SurfaceHolder.Callback
|
||||
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
val surface = holder.surface
|
||||
surfaceReady = true
|
||||
|
||||
if (rendererStarted) {
|
||||
// The previous Surface reference is holder-owned; do NOT release
|
||||
// it (SurfaceView manages its lifecycle). Just track the new one.
|
||||
activeSurface = surface
|
||||
renderer?.attachSurface(surface)
|
||||
// Push the actual view dimensions immediately so mpv doesn't
|
||||
// render against stale full-screen geometry during PiP transitions.
|
||||
syncSurfaceSizeToView()
|
||||
}
|
||||
|
||||
// If we have a pending load, execute it now
|
||||
pendingConfig?.let { config ->
|
||||
ensureRendererStarted(config.voDriver)
|
||||
loadVideoInternal(config)
|
||||
pendingConfig = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
if (width > 0 && height > 0) {
|
||||
renderer?.updateSurfaceSize(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
surfaceReady = false
|
||||
renderer?.detachSurface()
|
||||
// Do NOT issue mpv "stop" here. Playback continues against the
|
||||
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
|
||||
// background/foreground), we re-attach and frames resume. This
|
||||
// matches the keep-open=always setting in MPVLayerRenderer.
|
||||
//
|
||||
// Do NOT release activeSurface — SurfaceView owns it via the holder.
|
||||
activeSurface = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the actual SurfaceView width/height and push them to mpv.
|
||||
* The PiP transition can fire surfaceCreated before the view's layout
|
||||
* has settled to PiP dimensions, so we re-sync after layout passes.
|
||||
*/
|
||||
private fun syncSurfaceSizeToView() {
|
||||
if (!surfaceReady) return
|
||||
val w = surfaceView.width
|
||||
val h = surfaceView.height
|
||||
if (w > 0 && h > 0) {
|
||||
renderer?.updateSurfaceSize(w, h)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Loading
|
||||
|
||||
fun loadVideo(config: VideoLoadConfig) {
|
||||
// Skip reload if same URL is already playing
|
||||
if (currentUrl == config.url) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!surfaceReady) {
|
||||
// Surface not ready, store config and load when ready
|
||||
pendingConfig = config
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure renderer is started with the configured VO driver
|
||||
ensureRendererStarted(config.voDriver)
|
||||
|
||||
loadVideoInternal(config)
|
||||
}
|
||||
|
||||
private fun loadVideoInternal(config: VideoLoadConfig) {
|
||||
currentUrl = config.url
|
||||
|
||||
renderer?.load(
|
||||
url = config.url,
|
||||
headers = config.headers,
|
||||
startPosition = config.startPosition,
|
||||
externalSubtitles = config.externalSubtitles,
|
||||
initialSubtitleId = config.initialSubtitleId,
|
||||
initialAudioId = config.initialAudioId,
|
||||
cacheEnabled = config.cacheEnabled,
|
||||
cacheSeconds = config.cacheSeconds,
|
||||
demuxerMaxBytes = config.demuxerMaxBytes,
|
||||
demuxerMaxBackBytes = config.demuxerMaxBackBytes
|
||||
)
|
||||
|
||||
if (config.autoplay) {
|
||||
play()
|
||||
}
|
||||
|
||||
onLoad(mapOf("url" to config.url))
|
||||
}
|
||||
|
||||
// Convenience method for simple loads
|
||||
fun loadVideo(url: String, headers: Map<String, String>? = null) {
|
||||
loadVideo(VideoLoadConfig(url = url, headers = headers))
|
||||
}
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
fun play() {
|
||||
intendedPlayState = true
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
intendedPlayState = false
|
||||
renderer?.pause()
|
||||
pipController?.setPlaybackRate(0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback and release decoder resources.
|
||||
*
|
||||
* Delegates to [MPVLayerRenderer.stop], which issues mpv's "stop" command
|
||||
* on a background thread (flushing the demuxer and releasing the
|
||||
* MediaCodec hardware decoder) and drops the per-instance mpv handle.
|
||||
*
|
||||
* NOTE: this does NOT call `LibMPV.destroy()`. libmpv 1.0's
|
||||
* nativeDestroy has an internal use-after-free on the JNI global ref
|
||||
* path, so the native mpv handle is intentionally left for the JVM GC
|
||||
* / native finalizer rather than torn down synchronously. See
|
||||
* [MPVLib] class doc for the full rationale.
|
||||
*
|
||||
* Call this BEFORE navigating away from the player screen so the
|
||||
* decoder is reclaimed before the next screen (or the next episode's
|
||||
* player) mounts. Otherwise Expo Router renders the new screen first
|
||||
* and you briefly have two mpv instances + two 4K decoders alive —
|
||||
* instant OOM on a 2 GB device.
|
||||
*/
|
||||
fun destroy() {
|
||||
renderer?.stop()
|
||||
|
||||
// Reset view-level state so a subsequent loadVideo() on the SAME view
|
||||
// instance re-creates the mpv handle and re-attaches the still-live
|
||||
// SurfaceView surface. Without this, rendererStarted stays true and
|
||||
// ensureRendererStarted() early-returns, so renderer.start() is never
|
||||
// called again — but stop() already nulled the renderer's mpv handle.
|
||||
// The next loadVideo() then runs loadVideoInternal() -> renderer.load()
|
||||
// against mpv == null, where every mpv?.command() (including the
|
||||
// "stop" and load commands) silently no-ops, leaving a black frame.
|
||||
//
|
||||
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
||||
// which call destroy() immediately before router.replace() to the
|
||||
// same route — Expo Router reuses the same MpvPlayerView instance,
|
||||
// so the next source load happens on this view without a remount.
|
||||
//
|
||||
// SurfaceView note: the surface is owned by the holder and survives
|
||||
// across destroy()/loadVideo() on the same view instance. The next
|
||||
// ensureRendererStarted() reads it from surfaceView.holder.surface.
|
||||
rendererStarted = false
|
||||
currentUrl = null
|
||||
activeSurface = null
|
||||
}
|
||||
|
||||
fun seekTo(position: Double) {
|
||||
renderer?.seekTo(position)
|
||||
}
|
||||
|
||||
fun seekBy(offset: Double) {
|
||||
renderer?.seekBy(offset)
|
||||
}
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
renderer?.setSpeed(speed)
|
||||
}
|
||||
|
||||
fun getSpeed(): Double {
|
||||
return renderer?.getSpeed() ?: 1.0
|
||||
}
|
||||
|
||||
fun isPaused(): Boolean {
|
||||
return renderer?.isPausedState ?: true
|
||||
}
|
||||
|
||||
fun getCurrentPosition(): Double {
|
||||
return cachedPosition
|
||||
}
|
||||
|
||||
fun getDuration(): Double {
|
||||
return cachedDuration
|
||||
}
|
||||
|
||||
// MARK: - Picture in Picture
|
||||
|
||||
fun startPictureInPicture() {
|
||||
pipController?.startPictureInPicture()
|
||||
}
|
||||
|
||||
fun stopPictureInPicture() {
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
fun isPictureInPictureSupported(): Boolean {
|
||||
return pipController?.isPictureInPictureSupported() ?: false
|
||||
}
|
||||
|
||||
fun isPictureInPictureActive(): Boolean {
|
||||
return pipController?.isPictureInPictureActive() ?: false
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
|
||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||
return renderer?.getSubtitleTracks() ?: emptyList()
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
renderer?.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
fun disableSubtitles() {
|
||||
renderer?.disableSubtitles()
|
||||
}
|
||||
|
||||
fun getCurrentSubtitleTrack(): Int {
|
||||
return renderer?.getCurrentSubtitleTrack() ?: 0
|
||||
}
|
||||
|
||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||
renderer?.addSubtitleFile(url, select)
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
fun setSubtitlePosition(position: Int) {
|
||||
renderer?.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
fun setSubtitleScale(scale: Double) {
|
||||
renderer?.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
fun setSubtitleMarginY(margin: Int) {
|
||||
renderer?.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
fun setSubtitleAlignX(alignment: String) {
|
||||
renderer?.setSubtitleAlignX(alignment)
|
||||
}
|
||||
|
||||
fun setSubtitleAlignY(alignment: String) {
|
||||
renderer?.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
fun setSubtitleFontSize(size: Int) {
|
||||
renderer?.setSubtitleFontSize(size)
|
||||
}
|
||||
|
||||
fun setSubtitleBorderStyle(style: String) {
|
||||
renderer?.setSubtitleBorderStyle(style)
|
||||
}
|
||||
|
||||
fun setSubtitleBackgroundColor(color: String) {
|
||||
renderer?.setSubtitleBackgroundColor(color)
|
||||
}
|
||||
|
||||
fun setSubtitleAssOverride(mode: String) {
|
||||
renderer?.setSubtitleAssOverride(mode)
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
|
||||
fun getAudioTracks(): List<Map<String, Any>> {
|
||||
return renderer?.getAudioTracks() ?: emptyList()
|
||||
}
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
renderer?.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
fun getCurrentAudioTrack(): Int {
|
||||
return renderer?.getCurrentAudioTrack() ?: 0
|
||||
}
|
||||
|
||||
// MARK: - Video Scaling
|
||||
|
||||
private var _isZoomedToFill: Boolean = false
|
||||
|
||||
fun setZoomedToFill(zoomed: Boolean) {
|
||||
_isZoomedToFill = zoomed
|
||||
renderer?.setZoomedToFill(zoomed)
|
||||
}
|
||||
|
||||
fun isZoomedToFill(): Boolean {
|
||||
return _isZoomedToFill
|
||||
}
|
||||
|
||||
// MARK: - Technical Info
|
||||
|
||||
fun getTechnicalInfo(): Map<String, Any> {
|
||||
return renderer?.getTechnicalInfo() ?: emptyMap()
|
||||
}
|
||||
|
||||
// MARK: - MPVLayerRenderer.Delegate
|
||||
|
||||
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
|
||||
cachedPosition = position
|
||||
cachedDuration = duration
|
||||
|
||||
// Update PiP progress
|
||||
if (pipController?.isPictureInPictureActive() == true) {
|
||||
pipController?.setCurrentTime(position, duration)
|
||||
}
|
||||
|
||||
onProgress(mapOf(
|
||||
"position" to position,
|
||||
"duration" to duration,
|
||||
"progress" to if (duration > 0) position / duration else 0.0,
|
||||
"cacheSeconds" to cacheSeconds
|
||||
))
|
||||
}
|
||||
|
||||
override fun onPauseChanged(isPaused: Boolean) {
|
||||
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
|
||||
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isPaused" to isPaused,
|
||||
"isPlaying" to !isPaused
|
||||
))
|
||||
}
|
||||
|
||||
override fun onLoadingChanged(isLoading: Boolean) {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isLoading" to isLoading
|
||||
))
|
||||
}
|
||||
|
||||
override fun onReadyToSeek() {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isReadyToSeek" to true
|
||||
))
|
||||
}
|
||||
|
||||
override fun onTracksReady() {
|
||||
onTracksReady(emptyMap<String, Any>())
|
||||
}
|
||||
|
||||
override fun onVideoDimensionsChanged(width: Int, height: Int) {
|
||||
pipController?.setVideoDimensions(width, height)
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
onError(mapOf("error" to message))
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/**
|
||||
* Proactively tear down the player. Called from onDetachedFromWindow so
|
||||
* the app releases mpv + decoder buffers when the View detaches from the
|
||||
* window. The JS-facing destroy() is intentionally thinner (just
|
||||
* renderer.stop()) — see this thread for why the full teardown was kept
|
||||
* off the JS path.
|
||||
*/
|
||||
fun cleanup() {
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
renderer?.delegate = null
|
||||
|
||||
// SurfaceView owns the Surface via its holder — do NOT release it.
|
||||
activeSurface = null
|
||||
surfaceReady = false
|
||||
currentUrl = null
|
||||
rendererStarted = false
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Icon
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.util.Rational
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import expo.modules.kotlin.AppContext
|
||||
|
||||
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PiPController"
|
||||
private const val DEFAULT_ASPECT_WIDTH = 16
|
||||
private const val DEFAULT_ASPECT_HEIGHT = 9
|
||||
private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE"
|
||||
private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD"
|
||||
private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD"
|
||||
}
|
||||
|
||||
interface Delegate {
|
||||
fun onPlay()
|
||||
fun onPause()
|
||||
fun onSeekBy(seconds: Double)
|
||||
fun onPictureInPictureModeChanged(isInPiP: Boolean)
|
||||
}
|
||||
|
||||
var delegate: Delegate? = null
|
||||
|
||||
private var currentPosition: Double = 0.0
|
||||
private var currentDuration: Double = 0.0
|
||||
private var playbackRate: Double = 1.0
|
||||
// Independently tracks whether the system should auto-enter PiP on home
|
||||
// press. Decoupled from playbackRate so that disabling auto-enter
|
||||
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
|
||||
// state that buildPiPActions() derives from playbackRate.
|
||||
private var autoEnterEnabled: Boolean = false
|
||||
|
||||
private var videoWidth: Int = 0
|
||||
private var videoHeight: Int = 0
|
||||
private var playerView: View? = null
|
||||
|
||||
// PiP state tracking
|
||||
private var isInPiPMode: Boolean = false
|
||||
private var pipEntryNotified: Boolean = false
|
||||
private val pipHandler = Handler(Looper.getMainLooper())
|
||||
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
||||
private var lifecycleRegistered = false
|
||||
private var pipBroadcastReceiver: BroadcastReceiver? = null
|
||||
|
||||
fun isPictureInPictureSupported(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isPictureInPictureActive(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
return activity?.isInPictureInPictureMode ?: false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun startPictureInPicture() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
val activity = getActivity() ?: run {
|
||||
Log.e(TAG, "Cannot start PiP: no activity")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPictureInPictureSupported()) {
|
||||
Log.e(TAG, "PiP not supported on this device")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val params = buildPiPParams(forEntering = true)
|
||||
val result = activity.enterPictureInPictureMode(params)
|
||||
|
||||
if (!result) {
|
||||
Log.e(TAG, "enterPictureInPictureMode rejected by system")
|
||||
isInPiPMode = false
|
||||
return
|
||||
}
|
||||
|
||||
isInPiPMode = true
|
||||
pipEntryNotified = true
|
||||
delegate?.onPictureInPictureModeChanged(true)
|
||||
registerLifecycleCallbacks()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to enter PiP: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun stopPictureInPicture() {
|
||||
// Disable auto-enter eligibility without touching playbackRate.
|
||||
// playbackRate drives the play/pause icon in buildPiPActions();
|
||||
// mutating it here would cause a stale icon if PiP is re-entered
|
||||
// before the next playback state callback corrects it.
|
||||
autoEnterEnabled = false
|
||||
isInPiPMode = false
|
||||
pipEntryNotified = false
|
||||
unregisterLifecycleCallbacks()
|
||||
|
||||
val activity = getActivity() ?: return
|
||||
|
||||
// Push minimal params with just auto-enter disabled. Do NOT call
|
||||
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
|
||||
// setActions(), which would re-register the broadcast receiver
|
||||
// (just unregistered above) and attach play/pause/skip actions to
|
||||
// params being torn down. That leaves a live receiver + stale
|
||||
// actions after the player has unmounted.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
try {
|
||||
activity.setPictureInPictureParams(
|
||||
PictureInPictureParams.Builder()
|
||||
.setAutoEnterEnabled(false)
|
||||
.build()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
|
||||
}
|
||||
}
|
||||
if (activity.isInPictureInPictureMode) {
|
||||
activity.moveTaskToBack(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
||||
|
||||
fun setCurrentTime(position: Double, duration: Double) {
|
||||
currentPosition = position
|
||||
currentDuration = duration
|
||||
}
|
||||
|
||||
fun setPlaybackRate(rate: Double) {
|
||||
playbackRate = rate
|
||||
autoEnterEnabled = rate > 0
|
||||
|
||||
if (rate > 0) {
|
||||
registerLifecycleCallbacks()
|
||||
}
|
||||
|
||||
// Update PiP params so autoEnterEnabled and action icons track play/pause state
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
if (activity != null) {
|
||||
try {
|
||||
activity.setPictureInPictureParams(buildPiPParams())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to update PiP params: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setVideoDimensions(width: Int, height: Int) {
|
||||
if (width > 0 && height > 0) {
|
||||
videoWidth = width
|
||||
videoHeight = height
|
||||
updatePiPParamsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
fun setPlayerView(view: View?) {
|
||||
playerView = view
|
||||
}
|
||||
|
||||
private fun updatePiPParamsIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
if (activity?.isInPictureInPictureMode == true) {
|
||||
try {
|
||||
activity.setPictureInPictureParams(buildPiPParams())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to update PiP params: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
|
||||
val view = playerView
|
||||
val viewWidth = view?.width ?: 0
|
||||
val viewHeight = view?.height ?: 0
|
||||
|
||||
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
|
||||
|
||||
// Video aspect ratio with 2.39:1 clamping
|
||||
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
|
||||
Rational(
|
||||
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
|
||||
videoHeight.coerceAtMost((videoWidth * 2.39f).toInt())
|
||||
)
|
||||
} else {
|
||||
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
|
||||
}
|
||||
|
||||
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
|
||||
if (displayAspectRatio < aspectRatio) {
|
||||
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
|
||||
Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space)
|
||||
} else {
|
||||
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
|
||||
Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val builder = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(aspectRatio)
|
||||
|
||||
sourceRectHint?.let { builder.setSourceRectHint(it) }
|
||||
|
||||
ensurePiPReceiverRegistered()
|
||||
builder.setActions(buildPiPActions())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun getActivity(): Activity? {
|
||||
appContext?.currentActivity?.let { return it }
|
||||
|
||||
var ctx = context
|
||||
while (ctx is android.content.ContextWrapper) {
|
||||
if (ctx is Activity) return ctx
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle-based PiP Detection
|
||||
|
||||
private fun registerLifecycleCallbacks() {
|
||||
if (lifecycleRegistered) return
|
||||
|
||||
val app = context.applicationContext as? Application ?: run {
|
||||
Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling")
|
||||
startFallbackPolling()
|
||||
return
|
||||
}
|
||||
|
||||
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (!isInPiPMode) return
|
||||
if (!activity.isInPictureInPictureMode) {
|
||||
isInPiPMode = false
|
||||
pipEntryNotified = false
|
||||
delegate?.onPictureInPictureModeChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
// Proactively hide controls when user leaves while playing,
|
||||
// before the PiP window captures the UI. onActivityStopped
|
||||
// will restore if PiP didn't actually enter.
|
||||
if (playbackRate > 0 && !isInPiPMode) {
|
||||
isInPiPMode = true
|
||||
pipEntryNotified = true
|
||||
delegate?.onPictureInPictureModeChanged(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
pipHandler.postDelayed({
|
||||
val inPip = activity.isInPictureInPictureMode
|
||||
|
||||
if (inPip && !isInPiPMode) {
|
||||
isInPiPMode = true
|
||||
pipEntryNotified = true
|
||||
delegate?.onPictureInPictureModeChanged(true)
|
||||
return@postDelayed
|
||||
}
|
||||
|
||||
if (!isInPiPMode) return@postDelayed
|
||||
if (inPip) return@postDelayed
|
||||
|
||||
// Not in PiP after 1s — check again to avoid false positive during transition
|
||||
pipHandler.postDelayed({
|
||||
if (!isInPiPMode) return@postDelayed
|
||||
if (!activity.isInPictureInPictureMode) {
|
||||
isInPiPMode = false
|
||||
pipEntryNotified = false
|
||||
delegate?.onPictureInPictureModeChanged(false)
|
||||
}
|
||||
}, 1500)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
isInPiPMode = false
|
||||
}
|
||||
}
|
||||
|
||||
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
|
||||
lifecycleRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterLifecycleCallbacks() {
|
||||
if (!lifecycleRegistered) return
|
||||
lifecycleCallbacks?.let {
|
||||
(context.applicationContext as? Application)
|
||||
?.unregisterActivityLifecycleCallbacks(it)
|
||||
}
|
||||
lifecycleCallbacks = null
|
||||
lifecycleRegistered = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
unregisterPiPBroadcastReceiver()
|
||||
}
|
||||
|
||||
private fun startFallbackPolling() {
|
||||
var falseReadCount = 0
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipHandler.postDelayed(object : Runnable {
|
||||
override fun run() {
|
||||
if (!isInPiPMode) return
|
||||
|
||||
var ctx = context
|
||||
var activity: Activity? = null
|
||||
while (ctx is android.content.ContextWrapper) {
|
||||
if (ctx is Activity) { activity = ctx; break }
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
|
||||
val stillInPip = activity?.isInPictureInPictureMode == true
|
||||
|
||||
if (!stillInPip) {
|
||||
falseReadCount++
|
||||
if (falseReadCount >= 3) {
|
||||
isInPiPMode = false
|
||||
delegate?.onPictureInPictureModeChanged(false)
|
||||
return
|
||||
}
|
||||
pipHandler.postDelayed(this, 500)
|
||||
return
|
||||
}
|
||||
|
||||
falseReadCount = 0
|
||||
pipHandler.postDelayed(this, 1000)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// MARK: - PiP Remote Actions
|
||||
|
||||
private fun ensurePiPReceiverRegistered() {
|
||||
if (pipBroadcastReceiver != null) return
|
||||
|
||||
pipBroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
ACTION_PIP_PLAY_PAUSE -> {
|
||||
if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay()
|
||||
}
|
||||
ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0)
|
||||
ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(ACTION_PIP_PLAY_PAUSE)
|
||||
addAction(ACTION_PIP_SKIP_FORWARD)
|
||||
addAction(ACTION_PIP_SKIP_BACKWARD)
|
||||
}
|
||||
val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Context.RECEIVER_EXPORTED
|
||||
} else {
|
||||
0
|
||||
}
|
||||
context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags)
|
||||
}
|
||||
|
||||
private fun unregisterPiPBroadcastReceiver() {
|
||||
pipBroadcastReceiver?.let {
|
||||
try {
|
||||
context.applicationContext.unregisterReceiver(it)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
pipBroadcastReceiver = null
|
||||
}
|
||||
|
||||
private fun buildPiPActions(): List<RemoteAction> {
|
||||
val isPlaying = playbackRate > 0
|
||||
|
||||
return listOf(
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, android.R.drawable.ic_media_rew),
|
||||
"Rewind", "Skip backward 10 seconds",
|
||||
createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD)
|
||||
),
|
||||
RemoteAction(
|
||||
Icon.createWithResource(
|
||||
context,
|
||||
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
|
||||
),
|
||||
if (isPlaying) "Pause" else "Play",
|
||||
if (isPlaying) "Pause playback" else "Resume playback",
|
||||
createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE)
|
||||
),
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, android.R.drawable.ic_media_ff),
|
||||
"Fast Forward", "Skip forward 10 seconds",
|
||||
createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPiPPendingIntent(action: String): android.app.PendingIntent {
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return android.app.PendingIntent.getBroadcast(
|
||||
context.applicationContext, 0, Intent(action), flags
|
||||
)
|
||||
}
|
||||
}
|
||||
9
modules/mpv-player/expo-module.config.json
Normal file
9
modules/mpv-player/expo-module.config.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"platforms": ["apple", "android", "web"],
|
||||
"apple": {
|
||||
"modules": ["MpvPlayerModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.mpvplayer.MpvPlayerModule"]
|
||||
}
|
||||
}
|
||||
6
modules/mpv-player/index.ts
Normal file
6
modules/mpv-player/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Reexport the native module. On web, it will be resolved to MpvPlayerModule.web.ts
|
||||
// and on native platforms to MpvPlayerModule.ts
|
||||
|
||||
export * from "./src/MpvPlayer.types";
|
||||
export { default } from "./src/MpvPlayerModule";
|
||||
export { default as MpvPlayerView } from "./src/MpvPlayerView";
|
||||
154
modules/mpv-player/ios/Logger.swift
Normal file
154
modules/mpv-player/ios/Logger.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
|
||||
final class Logger: @unchecked Sendable {
|
||||
static let shared = Logger()
|
||||
|
||||
struct LogEntry {
|
||||
let message: String
|
||||
let type: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "mpvkit.logger", attributes: .concurrent)
|
||||
private var logs: [LogEntry] = []
|
||||
private let logFileURL: URL
|
||||
|
||||
private let maxFileSize = 1024 * 512
|
||||
private let maxLogEntries = 1000
|
||||
|
||||
private init() {
|
||||
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
logFileURL = tmpDir.appendingPathComponent("logs.txt")
|
||||
}
|
||||
|
||||
func log(_ message: String, type: String = "General") {
|
||||
let entry = LogEntry(message: message, type: type, timestamp: Date())
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
self.logs.append(entry)
|
||||
|
||||
if self.logs.count > self.maxLogEntries {
|
||||
self.logs.removeFirst(self.logs.count - self.maxLogEntries)
|
||||
}
|
||||
|
||||
self.saveLogToFile(entry)
|
||||
self.debugLog(entry)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("LoggerNotification"), object: nil,
|
||||
userInfo: [
|
||||
"message": message,
|
||||
"type": type,
|
||||
"timestamp": entry.timestamp
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getLogs() -> String {
|
||||
var result = ""
|
||||
queue.sync {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||
result = logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
|
||||
.joined(separator: "\n----\n")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getLogsAsync() async -> String {
|
||||
return await withCheckedContinuation { continuation in
|
||||
queue.async {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||
let result = self.logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
|
||||
.joined(separator: "\n----\n")
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearLogs() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.logs.removeAll()
|
||||
try? FileManager.default.removeItem(at: self.logFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
func clearLogsAsync() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async(flags: .barrier) {
|
||||
self.logs.removeAll()
|
||||
try? FileManager.default.removeItem(at: self.logFileURL)
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveLogToFile(_ log: LogEntry) {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||
|
||||
let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n"
|
||||
|
||||
guard let data = logString.data(using: .utf8) else {
|
||||
print("Failed to encode log string to UTF-8")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: logFileURL.path) {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
|
||||
let fileSize = attributes[.size] as? UInt64 ?? 0
|
||||
|
||||
if fileSize + UInt64(data.count) > maxFileSize {
|
||||
self.truncateLogFile()
|
||||
}
|
||||
|
||||
if let handle = try? FileHandle(forWritingTo: logFileURL) {
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(data)
|
||||
handle.closeFile()
|
||||
}
|
||||
} else {
|
||||
try data.write(to: logFileURL)
|
||||
}
|
||||
} catch {
|
||||
print("Error managing log file: \(error)")
|
||||
try? data.write(to: logFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func truncateLogFile() {
|
||||
do {
|
||||
guard let content = try? String(contentsOf: logFileURL, encoding: .utf8),
|
||||
!content.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let entries = content.components(separatedBy: "\n---\n")
|
||||
guard entries.count > 10 else { return }
|
||||
|
||||
let keepCount = entries.count / 2
|
||||
let truncatedEntries = Array(entries.suffix(keepCount))
|
||||
let truncatedContent = truncatedEntries.joined(separator: "\n---\n")
|
||||
|
||||
if let truncatedData = truncatedContent.data(using: .utf8) {
|
||||
try truncatedData.write(to: logFileURL)
|
||||
}
|
||||
} catch {
|
||||
print("Error truncating log file: \(error)")
|
||||
try? FileManager.default.removeItem(at: logFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func debugLog(_ entry: LogEntry) {
|
||||
#if DEBUG
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||
let formattedMessage = "[\(dateFormatter.string(from: entry.timestamp))] [\(entry.type)] \(entry.message)"
|
||||
print(formattedMessage)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
1063
modules/mpv-player/ios/MPVLayerRenderer.swift
Normal file
1063
modules/mpv-player/ios/MPVLayerRenderer.swift
Normal file
File diff suppressed because it is too large
Load Diff
188
modules/mpv-player/ios/MPVNowPlayingManager.swift
Normal file
188
modules/mpv-player/ios/MPVNowPlayingManager.swift
Normal file
@@ -0,0 +1,188 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
20
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
20
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
@@ -0,0 +1,20 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'MpvPlayer'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'MPV-based video player for Streamyfin (Expo module)'
|
||||
s.author = 'Streamyfin'
|
||||
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||
s.platforms = { :ios => '15.1', :tvos => '15.1' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'MPVKit'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
225
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
225
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
@@ -0,0 +1,225 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class MpvPlayerModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("MpvPlayer")
|
||||
|
||||
// Defines event names that the module can send to JavaScript.
|
||||
Events("onChange")
|
||||
|
||||
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
|
||||
Function("hello") {
|
||||
return "Hello from MPV Player! 👋"
|
||||
}
|
||||
|
||||
// Defines a JavaScript function that always returns a Promise and whose native code
|
||||
// is by default dispatched on the different thread than the JavaScript runtime runs on.
|
||||
AsyncFunction("setValueAsync") { (value: String) in
|
||||
// Send an event to JavaScript.
|
||||
self.sendEvent("onChange", [
|
||||
"value": value
|
||||
])
|
||||
}
|
||||
|
||||
// Enables the module to be used as a native view. Definition components that are accepted as part of the
|
||||
// view definition: Prop, Events.
|
||||
View(MpvPlayerView.self) {
|
||||
// All video load options are passed via a single "source" prop
|
||||
Prop("source") { (view: MpvPlayerView, source: [String: Any]?) in
|
||||
guard let source = source,
|
||||
let urlString = source["url"] as? String,
|
||||
let videoURL = URL(string: urlString) else { return }
|
||||
|
||||
// Parse cache config if provided
|
||||
let cacheConfig = source["cacheConfig"] as? [String: Any]
|
||||
|
||||
let config = VideoLoadConfig(
|
||||
url: videoURL,
|
||||
headers: source["headers"] as? [String: String],
|
||||
externalSubtitles: source["externalSubtitles"] as? [String],
|
||||
startPosition: source["startPosition"] as? Double,
|
||||
autoplay: (source["autoplay"] as? Bool) ?? true,
|
||||
initialSubtitleId: source["initialSubtitleId"] as? Int,
|
||||
initialAudioId: source["initialAudioId"] as? Int,
|
||||
cacheEnabled: cacheConfig?["enabled"] as? String,
|
||||
cacheSeconds: cacheConfig?["cacheSeconds"] as? Int,
|
||||
demuxerMaxBytes: cacheConfig?["maxBytes"] as? Int,
|
||||
demuxerMaxBackBytes: cacheConfig?["maxBackBytes"] as? Int
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// Async function to pause video
|
||||
AsyncFunction("pause") { (view: MpvPlayerView) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
// Synchronously destroy mpv instance + decoder before navigating
|
||||
// away from the player screen (cross-platform; matches Android).
|
||||
AsyncFunction("destroy") { (view: MpvPlayerView) in
|
||||
view.destroy()
|
||||
}
|
||||
|
||||
// Async function to seek to position
|
||||
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
||||
view.seekTo(position: position)
|
||||
}
|
||||
|
||||
// Async function to seek by offset
|
||||
AsyncFunction("seekBy") { (view: MpvPlayerView, offset: Double) in
|
||||
view.seekBy(offset: offset)
|
||||
}
|
||||
|
||||
// Async function to set playback speed
|
||||
AsyncFunction("setSpeed") { (view: MpvPlayerView, speed: Double) in
|
||||
view.setSpeed(speed: speed)
|
||||
}
|
||||
|
||||
// Function to get current speed
|
||||
AsyncFunction("getSpeed") { (view: MpvPlayerView) -> Double in
|
||||
return view.getSpeed()
|
||||
}
|
||||
|
||||
// Function to check if paused
|
||||
AsyncFunction("isPaused") { (view: MpvPlayerView) -> Bool in
|
||||
return view.isPaused()
|
||||
}
|
||||
|
||||
// Function to get current position
|
||||
AsyncFunction("getCurrentPosition") { (view: MpvPlayerView) -> Double in
|
||||
return view.getCurrentPosition()
|
||||
}
|
||||
|
||||
// Function to get duration
|
||||
AsyncFunction("getDuration") { (view: MpvPlayerView) -> Double in
|
||||
return view.getDuration()
|
||||
}
|
||||
|
||||
// Picture in Picture functions
|
||||
AsyncFunction("startPictureInPicture") { (view: MpvPlayerView) in
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("stopPictureInPicture") { (view: MpvPlayerView) in
|
||||
view.stopPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureSupported") { (view: MpvPlayerView) -> Bool in
|
||||
return view.isPictureInPictureSupported()
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureActive") { (view: MpvPlayerView) -> Bool in
|
||||
return view.isPictureInPictureActive()
|
||||
}
|
||||
|
||||
// Subtitle functions
|
||||
AsyncFunction("getSubtitleTracks") { (view: MpvPlayerView) -> [[String: Any]] in
|
||||
return view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { (view: MpvPlayerView, trackId: Int) in
|
||||
view.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("disableSubtitles") { (view: MpvPlayerView) in
|
||||
view.disableSubtitles()
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentSubtitleTrack") { (view: MpvPlayerView) -> Int in
|
||||
return view.getCurrentSubtitleTrack()
|
||||
}
|
||||
|
||||
AsyncFunction("addSubtitleFile") { (view: MpvPlayerView, url: String, select: Bool) in
|
||||
view.addSubtitleFile(url: url, select: select)
|
||||
}
|
||||
|
||||
// Subtitle positioning functions
|
||||
AsyncFunction("setSubtitlePosition") { (view: MpvPlayerView, position: Int) in
|
||||
view.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleScale") { (view: MpvPlayerView, scale: Double) in
|
||||
view.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleMarginY") { (view: MpvPlayerView, margin: Int) in
|
||||
view.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignX") { (view: MpvPlayerView, alignment: String) in
|
||||
view.setSubtitleAlignX(alignment)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignY") { (view: MpvPlayerView, alignment: String) in
|
||||
view.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleFontSize") { (view: MpvPlayerView, size: Int) in
|
||||
view.setSubtitleFontSize(size)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleBackgroundColor") { (view: MpvPlayerView, color: String) in
|
||||
view.setSubtitleBackgroundColor(color)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleBorderStyle") { (view: MpvPlayerView, style: String) in
|
||||
view.setSubtitleBorderStyle(style)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAssOverride") { (view: MpvPlayerView, mode: String) in
|
||||
view.setSubtitleAssOverride(mode)
|
||||
}
|
||||
|
||||
// Audio track functions
|
||||
AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]] in
|
||||
return view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { (view: MpvPlayerView, trackId: Int) in
|
||||
view.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentAudioTrack") { (view: MpvPlayerView) -> Int in
|
||||
return view.getCurrentAudioTrack()
|
||||
}
|
||||
|
||||
// Video scaling functions
|
||||
AsyncFunction("setZoomedToFill") { (view: MpvPlayerView, zoomed: Bool) in
|
||||
view.setZoomedToFill(zoomed)
|
||||
}
|
||||
|
||||
AsyncFunction("isZoomedToFill") { (view: MpvPlayerView) -> Bool in
|
||||
return view.isZoomedToFill()
|
||||
}
|
||||
|
||||
// Technical info function
|
||||
AsyncFunction("getTechnicalInfo") { (view: MpvPlayerView) -> [String: Any] in
|
||||
return view.getTechnicalInfo()
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
748
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
748
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
@@ -0,0 +1,748 @@
|
||||
import AVFAudio
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import ExpoModulesCore
|
||||
import MediaPlayer
|
||||
import UIKit
|
||||
|
||||
/// Configuration for loading a video
|
||||
struct VideoLoadConfig {
|
||||
let url: URL
|
||||
var headers: [String: String]?
|
||||
var externalSubtitles: [String]?
|
||||
var startPosition: Double?
|
||||
var autoplay: Bool
|
||||
/// MPV subtitle track ID to select on start (1-based, -1 to disable, nil to use default)
|
||||
var initialSubtitleId: Int?
|
||||
/// MPV audio track ID to select on start (1-based, nil to use default)
|
||||
var initialAudioId: Int?
|
||||
/// Cache/buffer settings
|
||||
var cacheEnabled: String? // "auto", "yes", or "no"
|
||||
var cacheSeconds: Int? // Seconds of video to buffer
|
||||
var demuxerMaxBytes: Int? // Max cache size in MB
|
||||
var demuxerMaxBackBytes: Int? // Max backward cache size in MB
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
headers: [String: String]? = nil,
|
||||
externalSubtitles: [String]? = nil,
|
||||
startPosition: Double? = nil,
|
||||
autoplay: Bool = true,
|
||||
initialSubtitleId: Int? = nil,
|
||||
initialAudioId: Int? = nil,
|
||||
cacheEnabled: String? = nil,
|
||||
cacheSeconds: Int? = nil,
|
||||
demuxerMaxBytes: Int? = nil,
|
||||
demuxerMaxBackBytes: Int? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
self.externalSubtitles = externalSubtitles
|
||||
self.startPosition = startPosition
|
||||
self.autoplay = autoplay
|
||||
self.initialSubtitleId = initialSubtitleId
|
||||
self.initialAudioId = initialAudioId
|
||||
self.cacheEnabled = cacheEnabled
|
||||
self.cacheSeconds = cacheSeconds
|
||||
self.demuxerMaxBytes = demuxerMaxBytes
|
||||
self.demuxerMaxBackBytes = demuxerMaxBackBytes
|
||||
}
|
||||
}
|
||||
|
||||
// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
||||
// to apply the proper styling (e.g. border radius and shadows).
|
||||
class MpvPlayerView: ExpoView {
|
||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||
private var renderer: MPVLayerRenderer?
|
||||
private var videoContainer: UIView!
|
||||
private var pipController: PiPController?
|
||||
let onLoad = EventDispatcher()
|
||||
let onPlaybackStateChange = EventDispatcher()
|
||||
let onProgress = EventDispatcher()
|
||||
let onError = EventDispatcher()
|
||||
let onTracksReady = EventDispatcher()
|
||||
let onPictureInPictureChange = EventDispatcher()
|
||||
|
||||
private var currentURL: URL?
|
||||
private var cachedPosition: Double = 0
|
||||
private var cachedDuration: Double = 0
|
||||
private var intendedPlayState: Bool = false
|
||||
private var _isZoomedToFill: Bool = false
|
||||
private var appStateObserver: NSObjectProtocol?
|
||||
|
||||
// Reference to now playing manager
|
||||
private let nowPlayingManager = MPVNowPlayingManager.shared
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupNotifications()
|
||||
setupView()
|
||||
}
|
||||
|
||||
private func setupView() {
|
||||
clipsToBounds = true
|
||||
backgroundColor = .black
|
||||
|
||||
videoContainer = UIView()
|
||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
videoContainer.backgroundColor = .black
|
||||
videoContainer.clipsToBounds = true
|
||||
addSubview(videoContainer)
|
||||
|
||||
displayLayer.frame = bounds
|
||||
displayLayer.videoGravity = .resizeAspect
|
||||
#if !os(tvOS)
|
||||
if #available(iOS 17.0, *) {
|
||||
displayLayer.wantsExtendedDynamicRangeContent = true
|
||||
}
|
||||
#endif
|
||||
displayLayer.backgroundColor = UIColor.black.cgColor
|
||||
videoContainer.layer.addSublayer(displayLayer)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
videoContainer.topAnchor.constraint(equalTo: topAnchor),
|
||||
videoContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
videoContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
videoContainer.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
renderer = MPVLayerRenderer(displayLayer: displayLayer)
|
||||
renderer?.delegate = self
|
||||
|
||||
// Setup PiP
|
||||
pipController = PiPController(sampleBufferDisplayLayer: displayLayer)
|
||||
pipController?.delegate = self
|
||||
|
||||
do {
|
||||
try renderer?.start()
|
||||
} catch {
|
||||
onError(["error": "Failed to start renderer: \(error.localizedDescription)"])
|
||||
}
|
||||
|
||||
// Pause playback when app enters background on tvOS
|
||||
#if os(tvOS)
|
||||
appStateObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.pause()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
displayLayer.frame = videoContainer.bounds
|
||||
displayLayer.isHidden = false
|
||||
displayLayer.opacity = 1.0
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
// MARK: - Audio Session & Notifications
|
||||
|
||||
private func configureAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
print("Failed to configure audio session: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Deactivate the session AND reset the category — `setActive(false)` alone
|
||||
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
|
||||
/// reactivation (foreground, route change, other modules) re-steals audio.
|
||||
private func tearDownAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
||||
}
|
||||
|
||||
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 {
|
||||
return
|
||||
}
|
||||
currentURL = config.url
|
||||
|
||||
let preset = PlayerPreset(
|
||||
id: .sdrRec709,
|
||||
title: "Default",
|
||||
summary: "Default playback preset",
|
||||
stream: nil,
|
||||
commands: []
|
||||
)
|
||||
|
||||
// Pass everything to the renderer - it handles start position and external subs
|
||||
renderer?.load(
|
||||
url: config.url,
|
||||
with: preset,
|
||||
headers: config.headers,
|
||||
startPosition: config.startPosition,
|
||||
externalSubtitles: config.externalSubtitles,
|
||||
initialSubtitleId: config.initialSubtitleId,
|
||||
initialAudioId: config.initialAudioId,
|
||||
cacheEnabled: config.cacheEnabled,
|
||||
cacheSeconds: config.cacheSeconds,
|
||||
demuxerMaxBytes: config.demuxerMaxBytes,
|
||||
demuxerMaxBackBytes: config.demuxerMaxBackBytes
|
||||
)
|
||||
|
||||
if config.autoplay {
|
||||
play()
|
||||
}
|
||||
|
||||
onLoad(["url": config.url.absoluteString])
|
||||
}
|
||||
|
||||
// Convenience method for simple loads
|
||||
func loadVideo(url: URL, headers: [String: String]? = nil) {
|
||||
loadVideo(config: VideoLoadConfig(url: url, headers: headers))
|
||||
}
|
||||
|
||||
func play() {
|
||||
intendedPlayState = true
|
||||
configureAudioSession()
|
||||
setupRemoteCommands()
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
intendedPlayState = false
|
||||
renderer?.pausePlayback()
|
||||
pipController?.setPlaybackRate(0.0)
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously stop and destroy the mpv instance + decoder so memory is
|
||||
* freed before the next screen mounts. Safe to call multiple times — the
|
||||
* underlying renderer.stop() guards against re-entry.
|
||||
*
|
||||
* Cross-platform counterpart of MpvPlayerView.destroy() on Android.
|
||||
*/
|
||||
func destroy() {
|
||||
renderer?.stop()
|
||||
|
||||
// Reset view state and re-create the mpv handle so a subsequent
|
||||
// loadVideo() on the SAME view instance can actually load.
|
||||
// Without this, stop() leaves renderer.mpv == nil, and the next
|
||||
// loadVideo(config:) calls renderer.load() which early-returns
|
||||
// at `guard let handle = self.mpv else { return }` — but only
|
||||
// after flipping isLoading = true and dispatching the loading
|
||||
// delegate callback, so the JS layer is stuck in a perpetual
|
||||
// "loading" state with no actual playback.
|
||||
//
|
||||
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
||||
// which call destroy() immediately before router.replace() to
|
||||
// the same route — Expo Router reuses the same MpvPlayerView
|
||||
// instance, so the next `source` prop update arrives on this
|
||||
// view without a remount. setupView() is otherwise the only
|
||||
// place start() is called, so without re-starting here the
|
||||
// renderer stays dead until the whole view is unmounted and
|
||||
// recreated.
|
||||
//
|
||||
// start() is idempotent (`guard !isRunning else { return }`)
|
||||
// and stop() has already nulled mpv synchronously before
|
||||
// dispatching the async mpv_terminate_destroy, so creating a
|
||||
// fresh handle here is safe even while the old handle's
|
||||
// teardown is still in flight on a background queue (libmpv
|
||||
// handles are independent).
|
||||
currentURL = nil
|
||||
intendedPlayState = false
|
||||
do {
|
||||
try renderer?.start()
|
||||
} catch {
|
||||
onError(["error": "Failed to restart renderer after destroy: \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func setSpeed(speed: Double) {
|
||||
renderer?.setSpeed(speed)
|
||||
}
|
||||
|
||||
func getSpeed() -> Double {
|
||||
return renderer?.getSpeed() ?? 1.0
|
||||
}
|
||||
|
||||
func isPaused() -> Bool {
|
||||
return renderer?.isPausedState ?? true
|
||||
}
|
||||
|
||||
func getCurrentPosition() -> Double {
|
||||
return cachedPosition
|
||||
}
|
||||
|
||||
func getDuration() -> Double {
|
||||
return cachedDuration
|
||||
}
|
||||
|
||||
// MARK: - Picture in Picture
|
||||
|
||||
func startPictureInPicture() {
|
||||
print("🎬 MpvPlayerView: startPictureInPicture called")
|
||||
print("🎬 Duration: \(getDuration()), IsPlaying: \(!isPaused())")
|
||||
pipController?.startPictureInPicture()
|
||||
}
|
||||
|
||||
func stopPictureInPicture() {
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
func isPictureInPictureSupported() -> Bool {
|
||||
return pipController?.isPictureInPictureSupported ?? false
|
||||
}
|
||||
|
||||
func isPictureInPictureActive() -> Bool {
|
||||
return pipController?.isPictureInPictureActive ?? false
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
|
||||
func getSubtitleTracks() -> [[String: Any]] {
|
||||
return renderer?.getSubtitleTracks() ?? []
|
||||
}
|
||||
|
||||
func setSubtitleTrack(_ trackId: Int) {
|
||||
renderer?.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
func disableSubtitles() {
|
||||
renderer?.disableSubtitles()
|
||||
}
|
||||
|
||||
func getCurrentSubtitleTrack() -> Int {
|
||||
return renderer?.getCurrentSubtitleTrack() ?? 0
|
||||
}
|
||||
|
||||
func addSubtitleFile(url: String, select: Bool = true) {
|
||||
renderer?.addSubtitleFile(url: url, select: select)
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
|
||||
func getAudioTracks() -> [[String: Any]] {
|
||||
return renderer?.getAudioTracks() ?? []
|
||||
}
|
||||
|
||||
func setAudioTrack(_ trackId: Int) {
|
||||
renderer?.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
func getCurrentAudioTrack() -> Int {
|
||||
return renderer?.getCurrentAudioTrack() ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
func setSubtitlePosition(_ position: Int) {
|
||||
renderer?.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
func setSubtitleScale(_ scale: Double) {
|
||||
renderer?.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
func setSubtitleMarginY(_ margin: Int) {
|
||||
renderer?.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
func setSubtitleAlignX(_ alignment: String) {
|
||||
renderer?.setSubtitleAlignX(alignment)
|
||||
}
|
||||
|
||||
func setSubtitleAlignY(_ alignment: String) {
|
||||
renderer?.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
func setSubtitleFontSize(_ size: Int) {
|
||||
renderer?.setSubtitleFontSize(size)
|
||||
}
|
||||
|
||||
func setSubtitleBackgroundColor(_ color: String) {
|
||||
renderer?.setSubtitleBackgroundColor(color)
|
||||
}
|
||||
|
||||
func setSubtitleBorderStyle(_ style: String) {
|
||||
renderer?.setSubtitleBorderStyle(style)
|
||||
}
|
||||
|
||||
func setSubtitleAssOverride(_ mode: String) {
|
||||
renderer?.setSubtitleAssOverride(mode)
|
||||
}
|
||||
|
||||
// MARK: - Video Scaling
|
||||
|
||||
func setZoomedToFill(_ zoomed: Bool) {
|
||||
_isZoomedToFill = zoomed
|
||||
displayLayer.videoGravity = zoomed ? .resizeAspectFill : .resizeAspect
|
||||
}
|
||||
|
||||
func isZoomedToFill() -> Bool {
|
||||
return _isZoomedToFill
|
||||
}
|
||||
|
||||
// MARK: - Technical Info
|
||||
|
||||
func getTechnicalInfo() -> [String: Any] {
|
||||
return renderer?.getTechnicalInfo() ?? [:]
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let observer = appStateObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
#if os(tvOS)
|
||||
resetDisplayCriteria()
|
||||
#endif
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
displayLayer.removeFromSuperlayer()
|
||||
clearNowPlayingInfo()
|
||||
tearDownAudioSession()
|
||||
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) {
|
||||
cachedPosition = position
|
||||
cachedDuration = duration
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
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,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)")
|
||||
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
|
||||
self.syncNowPlaying(isPlaying: !isPaused)
|
||||
self.onPlaybackStateChange([
|
||||
"isPaused": isPaused,
|
||||
"isPlaying": !isPaused,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVLayerRenderer, didChangeLoading isLoading: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
"isLoading": isLoading,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVLayerRenderer, didBecomeReadyToSeek: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
"isReadyToSeek": didBecomeReadyToSeek,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVLayerRenderer, didBecomeTracksReady: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onTracksReady([:])
|
||||
}
|
||||
}
|
||||
func renderer(_: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double) {
|
||||
#if os(tvOS)
|
||||
setDisplayCriteria(for: mode, fps: Float(fps))
|
||||
#endif
|
||||
}
|
||||
|
||||
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
|
||||
syncNowPlaying(isPlaying: !isPaused())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - tvOS HDR Display Criteria
|
||||
|
||||
#if os(tvOS)
|
||||
import AVKit
|
||||
import CoreMedia
|
||||
|
||||
extension MpvPlayerView {
|
||||
/// Creates a CMFormatDescription with HDR metadata for display criteria
|
||||
private func createHDRFormatDescription(hdrMode: HDRMode) -> CMFormatDescription? {
|
||||
var formatDescription: CMFormatDescription?
|
||||
|
||||
// Build extensions dictionary for HDR color properties
|
||||
var extensions: [String: Any] = [
|
||||
kCMFormatDescriptionExtension_FullRangeVideo as String: true
|
||||
]
|
||||
|
||||
switch hdrMode {
|
||||
case .hdr10, .dolbyVision:
|
||||
// HDR10 and Dolby Vision use BT.2020 primaries with PQ transfer function
|
||||
extensions[kCMFormatDescriptionExtension_ColorPrimaries as String] = kCMFormatDescriptionColorPrimaries_ITU_R_2020
|
||||
extensions[kCMFormatDescriptionExtension_TransferFunction as String] = kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ
|
||||
extensions[kCMFormatDescriptionExtension_YCbCrMatrix as String] = kCMFormatDescriptionYCbCrMatrix_ITU_R_2020
|
||||
case .hlg:
|
||||
// HLG uses BT.2020 primaries with HLG transfer function
|
||||
extensions[kCMFormatDescriptionExtension_ColorPrimaries as String] = kCMFormatDescriptionColorPrimaries_ITU_R_2020
|
||||
extensions[kCMFormatDescriptionExtension_TransferFunction as String] = kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG
|
||||
extensions[kCMFormatDescriptionExtension_YCbCrMatrix as String] = kCMFormatDescriptionYCbCrMatrix_ITU_R_2020
|
||||
case .sdr:
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a video format description with HDR extensions
|
||||
// Using HEVC codec type and 4K resolution as typical HDR parameters
|
||||
let status = CMVideoFormatDescriptionCreate(
|
||||
allocator: kCFAllocatorDefault,
|
||||
codecType: kCMVideoCodecType_HEVC,
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
extensions: extensions as CFDictionary,
|
||||
formatDescriptionOut: &formatDescription
|
||||
)
|
||||
|
||||
return status == noErr ? formatDescription : nil
|
||||
}
|
||||
|
||||
/// Sets the preferred display criteria for HDR content on tvOS
|
||||
func setDisplayCriteria(for hdrMode: HDRMode, fps: Float) {
|
||||
guard #available(tvOS 17.0, *) else {
|
||||
print("🎬 HDR: AVDisplayCriteria requires tvOS 17.0+")
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = self.window else {
|
||||
print("🎬 HDR: No window available for display criteria")
|
||||
return
|
||||
}
|
||||
|
||||
let manager = window.avDisplayManager
|
||||
|
||||
if hdrMode == .sdr {
|
||||
print("🎬 HDR: Setting display criteria to SDR (nil)")
|
||||
manager.preferredDisplayCriteria = nil
|
||||
return
|
||||
}
|
||||
|
||||
guard let formatDescription = createHDRFormatDescription(hdrMode: hdrMode) else {
|
||||
print("🎬 HDR: Failed to create format description for \(hdrMode)")
|
||||
return
|
||||
}
|
||||
|
||||
print("🎬 HDR: Setting display criteria to \(hdrMode), fps: \(fps)")
|
||||
manager.preferredDisplayCriteria = AVDisplayCriteria(
|
||||
refreshRate: fps,
|
||||
formatDescription: formatDescription
|
||||
)
|
||||
}
|
||||
|
||||
/// Resets display criteria when playback ends
|
||||
func resetDisplayCriteria() {
|
||||
guard #available(tvOS 17.0, *) else { return }
|
||||
guard let window = self.window else { return }
|
||||
print("🎬 HDR: Resetting display criteria")
|
||||
window.avDisplayManager.preferredDisplayCriteria = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - PiPControllerDelegate
|
||||
|
||||
extension MpvPlayerView: PiPControllerDelegate {
|
||||
func pipController(_ controller: PiPController, willStartPictureInPicture: Bool) {
|
||||
print("PiP will start")
|
||||
// Sync timebase before PiP starts for smooth transition
|
||||
renderer?.syncTimebase()
|
||||
// Set current time for PiP progress bar
|
||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||
|
||||
// Reset to fit for PiP (zoomed video doesn't display correctly in PiP)
|
||||
if _isZoomedToFill {
|
||||
displayLayer.videoGravity = .resizeAspect
|
||||
}
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) {
|
||||
print("PiP did start: \(didStartPictureInPicture)")
|
||||
// Ensure current time is synced when PiP starts
|
||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
|
||||
// is `false` when AVKit reports a failure to start, so reflect that.
|
||||
onPictureInPictureChange(["isActive": didStartPictureInPicture])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||
print("PiP will stop")
|
||||
// Sync timebase before returning from PiP
|
||||
renderer?.syncTimebase()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, didStopPictureInPicture: Bool) {
|
||||
print("PiP did stop")
|
||||
// Ensure timebase is synced after PiP ends
|
||||
renderer?.syncTimebase()
|
||||
pipController?.updatePlaybackState()
|
||||
|
||||
// Restore the user's zoom preference
|
||||
if _isZoomedToFill {
|
||||
displayLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
// Notify JS that PiP has fully stopped so the controls overlay can
|
||||
// be re-mounted when the user returns to full screen.
|
||||
onPictureInPictureChange(["isActive": false])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||
print("PiP restore user interface")
|
||||
completionHandler(true)
|
||||
}
|
||||
|
||||
func pipControllerPlay(_ controller: PiPController) {
|
||||
print("PiP play requested")
|
||||
intendedPlayState = true
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
}
|
||||
|
||||
func pipControllerPause(_ controller: PiPController) {
|
||||
print("PiP pause requested")
|
||||
intendedPlayState = false
|
||||
renderer?.pausePlayback()
|
||||
pipController?.setPlaybackRate(0.0)
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, skipByInterval interval: CMTime) {
|
||||
let seconds = CMTimeGetSeconds(interval)
|
||||
print("PiP skip by interval: \(seconds)")
|
||||
let target = max(0, cachedPosition + seconds)
|
||||
seekTo(position: target)
|
||||
}
|
||||
|
||||
func pipControllerIsPlaying(_ controller: PiPController) -> Bool {
|
||||
// Use intended state to ignore transient pauses during seeking
|
||||
return intendedPlayState
|
||||
}
|
||||
|
||||
func pipControllerDuration(_ controller: PiPController) -> Double {
|
||||
return getDuration()
|
||||
}
|
||||
|
||||
func pipControllerCurrentPosition(_ controller: PiPController) -> Double {
|
||||
return getCurrentPosition()
|
||||
}
|
||||
}
|
||||
243
modules/mpv-player/ios/PiPController.swift
Normal file
243
modules/mpv-player/ios/PiPController.swift
Normal file
@@ -0,0 +1,243 @@
|
||||
import AVKit
|
||||
import AVFoundation
|
||||
|
||||
protocol PiPControllerDelegate: AnyObject {
|
||||
func pipController(_ controller: PiPController, willStartPictureInPicture: Bool)
|
||||
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool)
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool)
|
||||
func pipController(_ controller: PiPController, didStopPictureInPicture: Bool)
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void)
|
||||
func pipControllerPlay(_ controller: PiPController)
|
||||
func pipControllerPause(_ controller: PiPController)
|
||||
func pipController(_ controller: PiPController, skipByInterval interval: CMTime)
|
||||
func pipControllerIsPlaying(_ controller: PiPController) -> Bool
|
||||
func pipControllerDuration(_ controller: PiPController) -> Double
|
||||
func pipControllerCurrentPosition(_ controller: PiPController) -> Double
|
||||
}
|
||||
|
||||
final class PiPController: NSObject {
|
||||
private var pipController: AVPictureInPictureController?
|
||||
private weak var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer?
|
||||
|
||||
weak var delegate: PiPControllerDelegate?
|
||||
|
||||
// Timebase for PiP progress tracking
|
||||
private var timebase: CMTimebase?
|
||||
|
||||
// Track current time for PiP progress
|
||||
private var currentTime: CMTime = .zero
|
||||
private var currentDuration: Double = 0
|
||||
|
||||
var isPictureInPictureSupported: Bool {
|
||||
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||
}
|
||||
|
||||
var isPictureInPictureActive: Bool {
|
||||
return pipController?.isPictureInPictureActive ?? false
|
||||
}
|
||||
|
||||
var isPictureInPicturePossible: Bool {
|
||||
return pipController?.isPictureInPicturePossible ?? false
|
||||
}
|
||||
|
||||
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
|
||||
self.sampleBufferDisplayLayer = sampleBufferDisplayLayer
|
||||
super.init()
|
||||
setupTimebase()
|
||||
setupPictureInPicture()
|
||||
}
|
||||
|
||||
private func setupTimebase() {
|
||||
// Create a timebase for tracking playback time
|
||||
var newTimebase: CMTimebase?
|
||||
let status = CMTimebaseCreateWithSourceClock(
|
||||
allocator: kCFAllocatorDefault,
|
||||
sourceClock: CMClockGetHostTimeClock(),
|
||||
timebaseOut: &newTimebase
|
||||
)
|
||||
|
||||
if status == noErr, let tb = newTimebase {
|
||||
timebase = tb
|
||||
CMTimebaseSetTime(tb, time: .zero)
|
||||
CMTimebaseSetRate(tb, rate: 0) // Start paused
|
||||
|
||||
// Set the control timebase on the display layer
|
||||
sampleBufferDisplayLayer?.controlTimebase = tb
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPictureInPicture() {
|
||||
guard isPictureInPictureSupported,
|
||||
let displayLayer = sampleBufferDisplayLayer else {
|
||||
return
|
||||
}
|
||||
|
||||
let contentSource = AVPictureInPictureController.ContentSource(
|
||||
sampleBufferDisplayLayer: displayLayer,
|
||||
playbackDelegate: self
|
||||
)
|
||||
|
||||
pipController = AVPictureInPictureController(contentSource: contentSource)
|
||||
pipController?.delegate = self
|
||||
pipController?.requiresLinearPlayback = false
|
||||
#if !os(tvOS)
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
#endif
|
||||
}
|
||||
|
||||
func startPictureInPicture() {
|
||||
guard let pipController = pipController,
|
||||
pipController.isPictureInPicturePossible else {
|
||||
return
|
||||
}
|
||||
|
||||
pipController.startPictureInPicture()
|
||||
}
|
||||
|
||||
func stopPictureInPicture() {
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
if Thread.isMainThread {
|
||||
pipController?.invalidatePlaybackState()
|
||||
} else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updatePlaybackState() {
|
||||
// Only invalidate when PiP is active to avoid "no context menu visible" warnings
|
||||
guard isPictureInPictureActive else { return }
|
||||
|
||||
if Thread.isMainThread {
|
||||
pipController?.invalidatePlaybackState()
|
||||
} else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the current playback time for PiP progress display
|
||||
func setCurrentTime(_ time: CMTime) {
|
||||
currentTime = time
|
||||
|
||||
// Update the timebase to reflect current position
|
||||
if let tb = timebase {
|
||||
CMTimebaseSetTime(tb, time: time)
|
||||
}
|
||||
|
||||
// Only invalidate when PiP is active to avoid unnecessary updates
|
||||
if isPictureInPictureActive {
|
||||
updatePlaybackState()
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the current playback time from seconds
|
||||
func setCurrentTimeFromSeconds(_ seconds: Double, duration: Double) {
|
||||
guard seconds >= 0 else { return }
|
||||
currentDuration = duration
|
||||
let time = CMTime(seconds: seconds, preferredTimescale: 1000)
|
||||
setCurrentTime(time)
|
||||
}
|
||||
|
||||
/// Updates the playback rate on the timebase (1.0 = playing, 0.0 = paused)
|
||||
func setPlaybackRate(_ rate: Float) {
|
||||
if let tb = timebase {
|
||||
CMTimebaseSetRate(tb, rate: Float64(rate))
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let tb = timebase {
|
||||
CMTimebaseSetRate(tb, rate: 0)
|
||||
}
|
||||
sampleBufferDisplayLayer?.controlTimebase = nil
|
||||
timebase = nil
|
||||
pipController?.delegate = nil
|
||||
pipController = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureControllerDelegate
|
||||
|
||||
extension PiPController: AVPictureInPictureControllerDelegate {
|
||||
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
delegate?.pipController(self, willStartPictureInPicture: true)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
delegate?.pipController(self, didStartPictureInPicture: true)
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
||||
print("Failed to start PiP: \(error)")
|
||||
delegate?.pipController(self, didStartPictureInPicture: false)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
delegate?.pipController(self, willStopPictureInPicture: true)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
delegate?.pipController(self, didStopPictureInPicture: true)
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
||||
delegate?.pipController(self, restoreUserInterfaceForPictureInPictureStop: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
|
||||
|
||||
extension PiPController: AVPictureInPictureSampleBufferPlaybackDelegate {
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) {
|
||||
if playing {
|
||||
delegate?.pipControllerPlay(self)
|
||||
} else {
|
||||
delegate?.pipControllerPause(self)
|
||||
}
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) {
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) {
|
||||
delegate?.pipController(self, skipByInterval: skipInterval)
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
return delegate?.pipControllerIsPlaying(self) ?? false
|
||||
}
|
||||
|
||||
var timeRangeForPlayback: CMTimeRange {
|
||||
let duration = delegate?.pipControllerDuration(self) ?? 0
|
||||
if duration > 0 {
|
||||
let cmDuration = CMTime(seconds: duration, preferredTimescale: 1000)
|
||||
return CMTimeRange(start: .zero, duration: cmDuration)
|
||||
}
|
||||
return CMTimeRange(start: .zero, duration: .positiveInfinity)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange {
|
||||
return timeRangeForPlayback
|
||||
}
|
||||
|
||||
func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
|
||||
return !isPlaying
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool, completion: @escaping () -> Void) {
|
||||
if playing {
|
||||
delegate?.pipControllerPlay(self)
|
||||
} else {
|
||||
delegate?.pipControllerPause(self)
|
||||
}
|
||||
completion()
|
||||
}
|
||||
}
|
||||
40
modules/mpv-player/ios/PlayerPreset.swift
Normal file
40
modules/mpv-player/ios/PlayerPreset.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
struct PlayerPreset: Identifiable, Hashable {
|
||||
enum Identifier: String, CaseIterable {
|
||||
case sdrRec709
|
||||
case hdr10
|
||||
case dolbyVisionP5
|
||||
case dolbyVisionP8
|
||||
}
|
||||
|
||||
struct Stream: Hashable {
|
||||
enum Source: Hashable {
|
||||
case remote(URL)
|
||||
case bundled(resource: String, withExtension: String)
|
||||
}
|
||||
|
||||
let source: Source
|
||||
let note: String
|
||||
|
||||
func resolveURL() -> URL? {
|
||||
switch source {
|
||||
case .remote(let url):
|
||||
return url
|
||||
case .bundled(let resource, let ext):
|
||||
return Bundle.main.url(forResource: resource, withExtension: ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id: Identifier
|
||||
let title: String
|
||||
let summary: String
|
||||
let stream: Stream?
|
||||
let commands: [[String]]
|
||||
|
||||
static var presets: [PlayerPreset] {
|
||||
let list: [PlayerPreset] = []
|
||||
return list
|
||||
}
|
||||
}
|
||||
178
modules/mpv-player/src/MpvPlayer.types.ts
Normal file
178
modules/mpv-player/src/MpvPlayer.types.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
export type OnLoadEventPayload = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type OnPlaybackStateChangePayload = {
|
||||
isPaused?: boolean;
|
||||
isPlaying?: boolean;
|
||||
isLoading?: boolean;
|
||||
isReadyToSeek?: boolean;
|
||||
};
|
||||
|
||||
export type OnProgressEventPayload = {
|
||||
position: number;
|
||||
duration: number;
|
||||
progress: number;
|
||||
/** Seconds of video buffered ahead of current position */
|
||||
cacheSeconds: number;
|
||||
};
|
||||
|
||||
export type OnErrorEventPayload = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||
|
||||
export type OnPictureInPictureChangePayload = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type NowPlayingMetadata = {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
albumTitle?: string;
|
||||
artworkUri?: string;
|
||||
};
|
||||
|
||||
export type MpvPlayerModuleEvents = {
|
||||
onChange: (params: ChangeEventPayload) => void;
|
||||
};
|
||||
|
||||
export type ChangeEventPayload = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type VideoSource = {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
externalSubtitles?: string[];
|
||||
startPosition?: number;
|
||||
autoplay?: boolean;
|
||||
/** MPV subtitle track ID to select on start (1-based, -1 to disable) */
|
||||
initialSubtitleId?: number;
|
||||
/** MPV audio track ID to select on start (1-based) */
|
||||
initialAudioId?: number;
|
||||
/** MPV cache/buffer configuration */
|
||||
cacheConfig?: {
|
||||
/** Whether caching is enabled: "auto" (default), "yes", or "no" */
|
||||
enabled?: "auto" | "yes" | "no";
|
||||
/** Seconds of video to buffer (default: 10, range: 5-120) */
|
||||
cacheSeconds?: number;
|
||||
/** Maximum cache size in MB (default: 150, range: 50-500) */
|
||||
maxBytes?: number;
|
||||
/** Maximum backward cache size in MB (default: 50, range: 25-200) */
|
||||
maxBackBytes?: number;
|
||||
};
|
||||
/** MPV video output driver (Android only) */
|
||||
voDriver?: "gpu-next" | "gpu";
|
||||
};
|
||||
|
||||
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;
|
||||
}) => void;
|
||||
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
||||
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
||||
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
||||
onPictureInPictureChange?: (event: {
|
||||
nativeEvent: OnPictureInPictureChangePayload;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export interface MpvPlayerViewRef {
|
||||
play: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
/**
|
||||
* Synchronously destroy the mpv instance + decoder + surface buffers.
|
||||
* Call before navigating away from the player screen so memory is
|
||||
* freed before the next screen mounts. Safe to call multiple times.
|
||||
*/
|
||||
destroy: () => Promise<void>;
|
||||
// Pre-libmpv-1.0 alias (kept for source-history reference):
|
||||
// stop: () => Promise<void>;
|
||||
seekTo: (position: number) => Promise<void>;
|
||||
seekBy: (offset: number) => Promise<void>;
|
||||
setSpeed: (speed: number) => Promise<void>;
|
||||
getSpeed: () => Promise<number>;
|
||||
isPaused: () => Promise<boolean>;
|
||||
getCurrentPosition: () => Promise<number>;
|
||||
getDuration: () => Promise<number>;
|
||||
startPictureInPicture: () => Promise<void>;
|
||||
stopPictureInPicture: () => Promise<void>;
|
||||
isPictureInPictureSupported: () => Promise<boolean>;
|
||||
isPictureInPictureActive: () => Promise<boolean>;
|
||||
// Subtitle controls
|
||||
getSubtitleTracks: () => Promise<SubtitleTrack[]>;
|
||||
setSubtitleTrack: (trackId: number) => Promise<void>;
|
||||
disableSubtitles: () => Promise<void>;
|
||||
getCurrentSubtitleTrack: () => Promise<number>;
|
||||
addSubtitleFile: (url: string, select?: boolean) => Promise<void>;
|
||||
// Subtitle positioning
|
||||
setSubtitlePosition: (position: number) => Promise<void>;
|
||||
setSubtitleScale: (scale: number) => Promise<void>;
|
||||
setSubtitleMarginY: (margin: number) => Promise<void>;
|
||||
setSubtitleAlignX: (alignment: "left" | "center" | "right") => Promise<void>;
|
||||
setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise<void>;
|
||||
setSubtitleFontSize: (size: number) => Promise<void>;
|
||||
setSubtitleBackgroundColor: (color: string) => Promise<void>;
|
||||
setSubtitleBorderStyle: (
|
||||
style: "outline-and-shadow" | "background-box",
|
||||
) => Promise<void>;
|
||||
setSubtitleAssOverride: (mode: "no" | "force") => Promise<void>;
|
||||
// Audio controls
|
||||
getAudioTracks: () => Promise<AudioTrack[]>;
|
||||
setAudioTrack: (trackId: number) => Promise<void>;
|
||||
getCurrentAudioTrack: () => Promise<number>;
|
||||
// Video scaling
|
||||
setZoomedToFill: (zoomed: boolean) => Promise<void>;
|
||||
isZoomedToFill: () => Promise<boolean>;
|
||||
// Technical info
|
||||
getTechnicalInfo: () => Promise<TechnicalInfo>;
|
||||
}
|
||||
|
||||
export type SubtitleTrack = {
|
||||
id: number;
|
||||
title?: string;
|
||||
lang?: string;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export type AudioTrack = {
|
||||
id: number;
|
||||
title?: string;
|
||||
lang?: string;
|
||||
codec?: string;
|
||||
channels?: number;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export type TechnicalInfo = {
|
||||
videoWidth?: number;
|
||||
videoHeight?: number;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
fps?: number;
|
||||
videoBitrate?: number;
|
||||
audioBitrate?: number;
|
||||
cacheSeconds?: number;
|
||||
/** Configured demuxer forward cache cap (MiB), read back from mpv */
|
||||
demuxerMaxBytes?: number;
|
||||
/** Configured demuxer backward cache cap (MiB), read back from mpv */
|
||||
demuxerMaxBackBytes?: number;
|
||||
/** Configured cache-secs floor, read back from mpv */
|
||||
cacheSecsLimit?: number;
|
||||
droppedFrames?: number;
|
||||
/** Active video output driver (read from MPV at runtime) */
|
||||
voDriver?: string;
|
||||
/** Active hardware decoder (read from MPV at runtime) */
|
||||
hwdec?: string;
|
||||
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
||||
estimatedVfFps?: number;
|
||||
};
|
||||
11
modules/mpv-player/src/MpvPlayerModule.ts
Normal file
11
modules/mpv-player/src/MpvPlayerModule.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NativeModule, requireNativeModule } from "expo";
|
||||
|
||||
import { MpvPlayerModuleEvents } from "./MpvPlayer.types";
|
||||
|
||||
declare class MpvPlayerModule extends NativeModule<MpvPlayerModuleEvents> {
|
||||
hello(): string;
|
||||
setValueAsync(value: string): Promise<void>;
|
||||
}
|
||||
|
||||
// This call loads the native module object from the JSI.
|
||||
export default requireNativeModule<MpvPlayerModule>("MpvPlayer");
|
||||
19
modules/mpv-player/src/MpvPlayerModule.web.ts
Normal file
19
modules/mpv-player/src/MpvPlayerModule.web.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NativeModule, registerWebModule } from "expo";
|
||||
|
||||
import { ChangeEventPayload } from "./MpvPlayer.types";
|
||||
|
||||
type MpvPlayerModuleEvents = {
|
||||
onChange: (params: ChangeEventPayload) => void;
|
||||
};
|
||||
|
||||
class MpvPlayerModule extends NativeModule<MpvPlayerModuleEvents> {
|
||||
PI = Math.PI;
|
||||
async setValueAsync(value: string): Promise<void> {
|
||||
this.emit("onChange", { value });
|
||||
}
|
||||
hello() {
|
||||
return "Hello world! 👋";
|
||||
}
|
||||
}
|
||||
|
||||
export default registerWebModule(MpvPlayerModule, "MpvPlayerModule");
|
||||
136
modules/mpv-player/src/MpvPlayerView.tsx
Normal file
136
modules/mpv-player/src/MpvPlayerView.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { requireNativeView } from "expo";
|
||||
import * as React from "react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
|
||||
import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
|
||||
|
||||
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||
requireNativeView("MpvPlayer");
|
||||
|
||||
const PIP_LOG = "[PiP] MpvPlayerView.tsx:";
|
||||
|
||||
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
function MpvPlayerView(props, ref) {
|
||||
const nativeRef = useRef<any>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
play: async () => {
|
||||
await nativeRef.current?.play();
|
||||
},
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
destroy: async () => {
|
||||
await nativeRef.current?.destroy();
|
||||
},
|
||||
seekTo: async (position: number) => {
|
||||
await nativeRef.current?.seekTo(position);
|
||||
},
|
||||
seekBy: async (offset: number) => {
|
||||
await nativeRef.current?.seekBy(offset);
|
||||
},
|
||||
setSpeed: async (speed: number) => {
|
||||
await nativeRef.current?.setSpeed(speed);
|
||||
},
|
||||
getSpeed: async () => {
|
||||
return await nativeRef.current?.getSpeed();
|
||||
},
|
||||
isPaused: async () => {
|
||||
return await nativeRef.current?.isPaused();
|
||||
},
|
||||
getCurrentPosition: async () => {
|
||||
return await nativeRef.current?.getCurrentPosition();
|
||||
},
|
||||
getDuration: async () => {
|
||||
return await nativeRef.current?.getDuration();
|
||||
},
|
||||
startPictureInPicture: async () => {
|
||||
console.log(PIP_LOG, "startPictureInPicture → native");
|
||||
await nativeRef.current?.startPictureInPicture();
|
||||
console.log(PIP_LOG, "startPictureInPicture ← native returned");
|
||||
},
|
||||
stopPictureInPicture: async () => {
|
||||
console.log(PIP_LOG, "stopPictureInPicture → native");
|
||||
await nativeRef.current?.stopPictureInPicture();
|
||||
console.log(PIP_LOG, "stopPictureInPicture ← native returned");
|
||||
},
|
||||
isPictureInPictureSupported: async () => {
|
||||
const result = await nativeRef.current?.isPictureInPictureSupported();
|
||||
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
|
||||
return result;
|
||||
},
|
||||
isPictureInPictureActive: async () => {
|
||||
const result = await nativeRef.current?.isPictureInPictureActive();
|
||||
console.log(PIP_LOG, "isPictureInPictureActive =", result);
|
||||
return result;
|
||||
},
|
||||
getSubtitleTracks: async () => {
|
||||
return await nativeRef.current?.getSubtitleTracks();
|
||||
},
|
||||
setSubtitleTrack: async (trackId: number) => {
|
||||
await nativeRef.current?.setSubtitleTrack(trackId);
|
||||
},
|
||||
disableSubtitles: async () => {
|
||||
await nativeRef.current?.disableSubtitles();
|
||||
},
|
||||
getCurrentSubtitleTrack: async () => {
|
||||
return await nativeRef.current?.getCurrentSubtitleTrack();
|
||||
},
|
||||
addSubtitleFile: async (url: string, select = true) => {
|
||||
await nativeRef.current?.addSubtitleFile(url, select);
|
||||
},
|
||||
setSubtitlePosition: async (position: number) => {
|
||||
await nativeRef.current?.setSubtitlePosition(position);
|
||||
},
|
||||
setSubtitleScale: async (scale: number) => {
|
||||
await nativeRef.current?.setSubtitleScale(scale);
|
||||
},
|
||||
setSubtitleMarginY: async (margin: number) => {
|
||||
await nativeRef.current?.setSubtitleMarginY(margin);
|
||||
},
|
||||
setSubtitleAlignX: async (alignment: "left" | "center" | "right") => {
|
||||
await nativeRef.current?.setSubtitleAlignX(alignment);
|
||||
},
|
||||
setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => {
|
||||
await nativeRef.current?.setSubtitleAlignY(alignment);
|
||||
},
|
||||
setSubtitleFontSize: async (size: number) => {
|
||||
await nativeRef.current?.setSubtitleFontSize(size);
|
||||
},
|
||||
setSubtitleBackgroundColor: async (color: string) => {
|
||||
await nativeRef.current?.setSubtitleBackgroundColor(color);
|
||||
},
|
||||
setSubtitleBorderStyle: async (
|
||||
style: "outline-and-shadow" | "background-box",
|
||||
) => {
|
||||
await nativeRef.current?.setSubtitleBorderStyle(style);
|
||||
},
|
||||
setSubtitleAssOverride: async (mode: "no" | "force") => {
|
||||
await nativeRef.current?.setSubtitleAssOverride(mode);
|
||||
},
|
||||
// Audio controls
|
||||
getAudioTracks: async () => {
|
||||
return await nativeRef.current?.getAudioTracks();
|
||||
},
|
||||
setAudioTrack: async (trackId: number) => {
|
||||
await nativeRef.current?.setAudioTrack(trackId);
|
||||
},
|
||||
getCurrentAudioTrack: async () => {
|
||||
return await nativeRef.current?.getCurrentAudioTrack();
|
||||
},
|
||||
// Video scaling
|
||||
setZoomedToFill: async (zoomed: boolean) => {
|
||||
await nativeRef.current?.setZoomedToFill(zoomed);
|
||||
},
|
||||
isZoomedToFill: async () => {
|
||||
return await nativeRef.current?.isZoomedToFill();
|
||||
},
|
||||
// Technical info
|
||||
getTechnicalInfo: async () => {
|
||||
return await nativeRef.current?.getTechnicalInfo();
|
||||
},
|
||||
}));
|
||||
|
||||
return <NativeView ref={nativeRef} {...props} />;
|
||||
},
|
||||
);
|
||||
17
modules/mpv-player/src/MpvPlayerView.web.tsx
Normal file
17
modules/mpv-player/src/MpvPlayerView.web.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MpvPlayerViewProps } from "./MpvPlayer.types";
|
||||
|
||||
export default function MpvPlayerView(props: MpvPlayerViewProps) {
|
||||
const url = props.source?.url ?? "";
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<iframe
|
||||
title={t("player.mpv_player_title")}
|
||||
style={{ flex: 1 }}
|
||||
src={url}
|
||||
onLoad={() => props.onLoad?.({ nativeEvent: { url } })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
modules/mpv-player/src/index.ts
Normal file
3
modules/mpv-player/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./MpvPlayer.types";
|
||||
export { default as MpvPlayerModule } from "./MpvPlayerModule";
|
||||
export { default as MpvPlayerView } from "./MpvPlayerView";
|
||||
6
modules/top-shelf-cache/expo-module.config.json
Normal file
6
modules/top-shelf-cache/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["TopShelfCacheModule"]
|
||||
}
|
||||
}
|
||||
1
modules/top-shelf-cache/index.ts
Normal file
1
modules/top-shelf-cache/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
23
modules/top-shelf-cache/ios/TopShelfCache.podspec
Normal file
23
modules/top-shelf-cache/ios/TopShelfCache.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'TopShelfCache'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Shared Top Shelf cache writer for Streamyfin tvOS'
|
||||
s.description = 'Writes lightweight Top Shelf cache payloads to an App Group container for the tvOS extension.'
|
||||
s.author = 'Streamyfin'
|
||||
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_VERSION' => '5.9'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift}"
|
||||
end
|
||||
112
modules/top-shelf-cache/ios/TopShelfCacheModule.swift
Normal file
112
modules/top-shelf-cache/ios/TopShelfCacheModule.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import ExpoModulesCore
|
||||
import Foundation
|
||||
import Security
|
||||
#if canImport(TVServices)
|
||||
import TVServices
|
||||
#endif
|
||||
|
||||
public class TopShelfCacheModule: Module {
|
||||
private let appGroupInfoPlistKey = "StreamyfinAppGroupIdentifier"
|
||||
private let keychainAccessGroupInfoPlistKey = "StreamyfinKeychainAccessGroupIdentifier"
|
||||
private let cacheKey = "TopShelfCache"
|
||||
private let apiKeyService = "StreamyfinTopShelf"
|
||||
private let apiKeyAccount = "JellyfinApiKey"
|
||||
private var appGroupIdentifier: String? {
|
||||
if let appGroupIdentifier = Bundle.main.object(
|
||||
forInfoDictionaryKey: appGroupInfoPlistKey
|
||||
) as? String {
|
||||
return appGroupIdentifier
|
||||
}
|
||||
|
||||
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return "group.\(bundleIdentifier)"
|
||||
}
|
||||
private var keychainAccessGroupIdentifier: String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: keychainAccessGroupInfoPlistKey) as? String
|
||||
}
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("TopShelfCache")
|
||||
|
||||
Function("writeCache") { (json: String, apiKey: String?) -> Bool in
|
||||
guard
|
||||
let appGroupIdentifier = appGroupIdentifier,
|
||||
let defaults = UserDefaults(suiteName: appGroupIdentifier)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
defaults.set(json, forKey: cacheKey)
|
||||
defaults.set(Date().timeIntervalSince1970, forKey: "\(cacheKey)UpdatedAt")
|
||||
defaults.synchronize()
|
||||
let didSaveAPIKey = saveAPIKey(apiKey)
|
||||
|
||||
#if canImport(TVServices)
|
||||
TVTopShelfContentProvider.topShelfContentDidChange()
|
||||
#endif
|
||||
|
||||
return didSaveAPIKey
|
||||
}
|
||||
|
||||
Function("clearCache") { () -> Bool in
|
||||
guard
|
||||
let appGroupIdentifier = appGroupIdentifier,
|
||||
let defaults = UserDefaults(suiteName: appGroupIdentifier)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
defaults.removeObject(forKey: cacheKey)
|
||||
defaults.removeObject(forKey: "\(cacheKey)UpdatedAt")
|
||||
defaults.synchronize()
|
||||
let didDeleteAPIKey = deleteAPIKey()
|
||||
|
||||
#if canImport(TVServices)
|
||||
TVTopShelfContentProvider.topShelfContentDidChange()
|
||||
#endif
|
||||
|
||||
return didDeleteAPIKey
|
||||
}
|
||||
}
|
||||
|
||||
private func baseAPIKeyQuery() -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: apiKeyService,
|
||||
kSecAttrAccount as String: apiKeyAccount
|
||||
]
|
||||
|
||||
if let keychainAccessGroupIdentifier {
|
||||
query[kSecAttrAccessGroup as String] = keychainAccessGroupIdentifier
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
private func saveAPIKey(_ apiKey: String?) -> Bool {
|
||||
guard deleteAPIKey() else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard
|
||||
let apiKey,
|
||||
!apiKey.isEmpty,
|
||||
let data = apiKey.data(using: .utf8)
|
||||
else {
|
||||
return true
|
||||
}
|
||||
|
||||
var query = baseAPIKeyQuery()
|
||||
query[kSecValueData as String] = data
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
return status == errSecSuccess
|
||||
}
|
||||
|
||||
private func deleteAPIKey() -> Bool {
|
||||
let status = SecItemDelete(baseAPIKeyQuery() as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
}
|
||||
21
modules/top-shelf-cache/src/TopShelfCache.types.ts
Normal file
21
modules/top-shelf-cache/src/TopShelfCache.types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type TopShelfCacheModuleEvents = Record<string, never>;
|
||||
|
||||
export interface TopShelfCacheItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
route: string;
|
||||
playRoute?: string;
|
||||
}
|
||||
|
||||
export interface TopShelfCacheSection {
|
||||
title: string;
|
||||
items: TopShelfCacheItem[];
|
||||
}
|
||||
|
||||
export interface TopShelfCachePayload {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
sections: TopShelfCacheSection[];
|
||||
}
|
||||
37
modules/top-shelf-cache/src/TopShelfCacheModule.ts
Normal file
37
modules/top-shelf-cache/src/TopShelfCacheModule.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NativeModule, requireNativeModule } from "expo";
|
||||
import { Platform } from "react-native";
|
||||
import type { TopShelfCacheModuleEvents } from "./TopShelfCache.types";
|
||||
|
||||
declare class TopShelfCacheModuleType extends NativeModule<TopShelfCacheModuleEvents> {
|
||||
writeCache(json: string, apiKey?: string): boolean;
|
||||
clearCache(): boolean;
|
||||
}
|
||||
|
||||
let TopShelfCacheNativeModule: TopShelfCacheModuleType | null = null;
|
||||
|
||||
if (Platform.OS === "ios" && Platform.isTV) {
|
||||
try {
|
||||
TopShelfCacheNativeModule =
|
||||
requireNativeModule<TopShelfCacheModuleType>("TopShelfCache");
|
||||
} catch {
|
||||
TopShelfCacheNativeModule = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTopShelfCache(json: string, apiKey?: string): boolean {
|
||||
if (!TopShelfCacheNativeModule) return false;
|
||||
|
||||
try {
|
||||
return TopShelfCacheNativeModule.writeCache(json, apiKey);
|
||||
} catch {
|
||||
try {
|
||||
return TopShelfCacheNativeModule.writeCache(json);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTopShelfCache(): boolean {
|
||||
return TopShelfCacheNativeModule?.clearCache() ?? false;
|
||||
}
|
||||
2
modules/top-shelf-cache/src/index.ts
Normal file
2
modules/top-shelf-cache/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./TopShelfCache.types";
|
||||
export { clearTopShelfCache, writeTopShelfCache } from "./TopShelfCacheModule";
|
||||
@@ -1,11 +1,10 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
}
|
||||
|
||||
group = 'expo.modules.vlcplayer'
|
||||
version = '0.6.0'
|
||||
group = 'expo.modules.tvrecommendations'
|
||||
version = '1.0.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||
@@ -18,30 +17,30 @@ useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
android {
|
||||
namespace "expo.modules.vlcplayer"
|
||||
|
||||
namespace "expo.modules.tvrecommendations"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.videolan.android:libvlc-all:3.6.0'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||
implementation "androidx.tvprovider:tvprovider:1.1.0"
|
||||
implementation "androidx.core:core-ktx:1.13.1"
|
||||
}
|
||||
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,25 @@
|
||||
package expo.modules.tvrecommendations
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class TvRecommendationsModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("TvRecommendations")
|
||||
|
||||
Function("syncRecommendations") { json: String ->
|
||||
val context = appContext.reactContext ?: return@Function false
|
||||
TvRecommendationsPublisher.sync(context, json)
|
||||
}
|
||||
|
||||
Function("clearRecommendations") {
|
||||
val context = appContext.reactContext ?: return@Function false
|
||||
TvRecommendationsPublisher.clear(context)
|
||||
}
|
||||
|
||||
Function("refreshRecommendations") {
|
||||
val context = appContext.reactContext ?: return@Function false
|
||||
TvRecommendationsPublisher.refreshFromCache(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
package expo.modules.tvrecommendations
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.tvprovider.media.tv.Channel
|
||||
import androidx.tvprovider.media.tv.PreviewProgram
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
|
||||
internal object TvRecommendationsPublisher {
|
||||
private const val TAG = "TvRecommendations"
|
||||
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
||||
private const val KEY_PAYLOAD = "payload"
|
||||
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
|
||||
private const val KEY_PROGRAM_IDS = "programIds"
|
||||
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
||||
|
||||
fun sync(context: Context, payloadJson: String): Boolean {
|
||||
val payload = try {
|
||||
JSONObject(payloadJson)
|
||||
} catch (error: Exception) {
|
||||
Log.e(TAG, "Failed to parse recommendations payload", error)
|
||||
return false
|
||||
}
|
||||
|
||||
val sectionCount = payload.optJSONArray("sections")?.length() ?: 0
|
||||
Log.d(TAG, "sync(): received payload with $sectionCount section(s)")
|
||||
|
||||
preferences(context)
|
||||
.edit()
|
||||
.putString(KEY_PAYLOAD, payloadJson)
|
||||
.apply()
|
||||
|
||||
return synchronize(context, payload)
|
||||
}
|
||||
|
||||
fun refreshFromCache(context: Context): Boolean {
|
||||
val payloadJson = preferences(context).getString(KEY_PAYLOAD, null) ?: return false
|
||||
val payload = try {
|
||||
JSONObject(payloadJson)
|
||||
} catch (error: Exception) {
|
||||
Log.e(TAG, "Failed to parse cached recommendations payload", error)
|
||||
return false
|
||||
}
|
||||
|
||||
val sectionCount = payload.optJSONArray("sections")?.length() ?: 0
|
||||
Log.d(TAG, "refreshFromCache(): replaying cached payload with $sectionCount section(s)")
|
||||
|
||||
return synchronize(context, payload)
|
||||
}
|
||||
|
||||
fun clear(context: Context): Boolean {
|
||||
val prefs = preferences(context)
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
|
||||
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||
if (allProgramIds != null) {
|
||||
var deletedPrograms = 0
|
||||
val channelKeys = allProgramIds.keys()
|
||||
while (channelKeys.hasNext()) {
|
||||
val channelIdStr = channelKeys.next()
|
||||
val programIdsJson = allProgramIds.optString(channelIdStr)
|
||||
if (programIdsJson.isBlank()) continue
|
||||
|
||||
try {
|
||||
val programIds = JSONObject(programIdsJson)
|
||||
val keys = programIds.keys()
|
||||
while (keys.hasNext()) {
|
||||
val providerId = keys.next()
|
||||
val programId = programIds.optLong(providerId, -1L)
|
||||
if (programId > 0L) {
|
||||
deletePreviewProgram(contentResolver, programId)
|
||||
deletedPrograms += 1
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
|
||||
}
|
||||
|
||||
// Notify the channel
|
||||
val channelId = channelIdStr.toLongOrNull() ?: -1L
|
||||
if (channelId > 0L) {
|
||||
try {
|
||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove per-channel pref
|
||||
prefs.edit().remove("programIds_$channelIdStr").apply()
|
||||
}
|
||||
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
||||
}
|
||||
|
||||
// Also handle legacy format (flat { providerId: programId }) for migration
|
||||
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||
if (legacyProgramIds != null) {
|
||||
val keys = legacyProgramIds.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
val programId = legacyProgramIds.optLong(key, -1L)
|
||||
if (programId > 0L) {
|
||||
deletePreviewProgram(contentResolver, programId)
|
||||
}
|
||||
}
|
||||
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
|
||||
}
|
||||
|
||||
prefs.edit()
|
||||
.remove(KEY_PAYLOAD)
|
||||
.remove(KEY_PROGRAM_IDS)
|
||||
.apply()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single preview program from the TvProvider.
|
||||
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
|
||||
*/
|
||||
fun deletePreviewProgram(context: Context, programId: Long) {
|
||||
try {
|
||||
context.contentResolver.delete(
|
||||
TvContractCompat.buildPreviewProgramUri(programId),
|
||||
null,
|
||||
null
|
||||
)
|
||||
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
|
||||
|
||||
// Also remove from stored programIds prefs
|
||||
removeProgramFromPrefs(context, programId)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
|
||||
try {
|
||||
contentResolver.delete(
|
||||
TvContractCompat.buildPreviewProgramUri(programId),
|
||||
null,
|
||||
null
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeProgramFromPrefs(context: Context, programId: Long) {
|
||||
val prefs = preferences(context)
|
||||
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
||||
try {
|
||||
val channelMap = JSONObject(programIdsJson)
|
||||
val channelKeys = channelMap.keys()
|
||||
while (channelKeys.hasNext()) {
|
||||
val channelId = channelKeys.next()
|
||||
val inner = channelMap.optJSONObject(channelId) ?: continue
|
||||
val providerKeys = inner.keys()
|
||||
while (providerKeys.hasNext()) {
|
||||
val providerId = providerKeys.next()
|
||||
if (inner.optLong(providerId, -1L) == programId) {
|
||||
inner.remove(providerId)
|
||||
if (inner.length() == 0) {
|
||||
channelMap.remove(channelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
||||
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
||||
if (sections.length() == 0) {
|
||||
Log.w(TAG, "synchronize(): no sections in payload")
|
||||
return false
|
||||
}
|
||||
|
||||
val prefs = preferences(context)
|
||||
val allNextProgramIds = JSONObject()
|
||||
var totalActive = 0
|
||||
var totalDeleted = 0
|
||||
|
||||
for (sectionIndex in 0 until sections.length()) {
|
||||
val section = sections.optJSONObject(sectionIndex) ?: continue
|
||||
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
||||
val items = section.optJSONArray("items") ?: JSONArray()
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
|
||||
)
|
||||
|
||||
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||
if (channelId <= 0L) {
|
||||
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
|
||||
continue
|
||||
}
|
||||
|
||||
// Per Android docs: check channel.isBrowsable() and request if needed.
|
||||
if (!isChannelBrowsable(context, channelId)) {
|
||||
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
|
||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||
}
|
||||
|
||||
val prefKey = "programIds_$channelId"
|
||||
val previousProgramIds = prefs.getString(prefKey, null)
|
||||
?.let(::JSONObject)
|
||||
?: JSONObject()
|
||||
val nextProgramIds = JSONObject()
|
||||
val activeProviderIds = mutableSetOf<String>()
|
||||
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.optJSONObject(index) ?: continue
|
||||
val providerId = item.optString("id")
|
||||
if (providerId.isBlank()) continue
|
||||
|
||||
val programId = upsertPreviewProgram(
|
||||
context = context,
|
||||
channelId = channelId,
|
||||
item = item,
|
||||
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
||||
weight = index
|
||||
)
|
||||
|
||||
if (programId > 0L) {
|
||||
activeProviderIds += providerId
|
||||
nextProgramIds.put(providerId, programId)
|
||||
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
||||
}
|
||||
}
|
||||
|
||||
var deletedPrograms = 0
|
||||
val previousKeys = previousProgramIds.keys()
|
||||
while (previousKeys.hasNext()) {
|
||||
val providerId = previousKeys.next()
|
||||
if (activeProviderIds.contains(providerId)) continue
|
||||
|
||||
val programId = previousProgramIds.optLong(providerId, -1L)
|
||||
if (programId > 0L) {
|
||||
deletePreviewProgram(context, programId)
|
||||
deletedPrograms += 1
|
||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
||||
}
|
||||
}
|
||||
|
||||
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
|
||||
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
|
||||
totalActive += activeProviderIds.size
|
||||
totalDeleted += deletedPrograms
|
||||
|
||||
logProviderState(context, channelId)
|
||||
}
|
||||
|
||||
// Store all channel program IDs for clear() to use
|
||||
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Query provider to check if a channel is browsable.
|
||||
* Per Android docs: "check channel.isBrowsable() before updating programs."
|
||||
*/
|
||||
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
|
||||
return try {
|
||||
context.contentResolver.query(
|
||||
TvContractCompat.buildChannelUri(channelId),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
|
||||
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
|
||||
true // Assume browsable if we can't check, to avoid blocking updates
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query provider to verify a channel actually exists.
|
||||
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
|
||||
* we must first check whether the channel was deleted by the system
|
||||
* or if the update simply failed for another reason.
|
||||
*/
|
||||
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
|
||||
return try {
|
||||
context.contentResolver.query(
|
||||
TvContractCompat.buildChannelUri(channelId),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
} ?: false
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
||||
val prefs = preferences(context)
|
||||
val channelKey = getChannelKey(displayName)
|
||||
val existingChannelId = prefs.getLong(channelKey, -1L)
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
if (existingChannelId > 0L) {
|
||||
// Query provider first to verify channel actually exists (prevents recreate bug)
|
||||
val exists = channelExistsInProvider(context, existingChannelId)
|
||||
|
||||
if (exists) {
|
||||
// Channel exists — update it in place, never recreate
|
||||
val updated = Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(displayName)
|
||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||
.build()
|
||||
|
||||
try {
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildChannelUri(existingChannelId),
|
||||
updated.toContentValues(),
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
if (updatedRows > 0) {
|
||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
||||
storeChannelLogo(context, existingChannelId)
|
||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
||||
return existingChannelId
|
||||
}
|
||||
|
||||
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
|
||||
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
|
||||
return existingChannelId
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
|
||||
return existingChannelId
|
||||
}
|
||||
}
|
||||
|
||||
// Channel truly doesn't exist in provider — recreate
|
||||
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
||||
prefs.edit().remove(channelKey).apply()
|
||||
}
|
||||
|
||||
// Create a new channel
|
||||
val channel = Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(displayName)
|
||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||
.build()
|
||||
|
||||
val channelUri = try {
|
||||
contentResolver.insert(
|
||||
TvContractCompat.Channels.CONTENT_URI,
|
||||
channel.toContentValues()
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
|
||||
null
|
||||
} ?: return -1L
|
||||
|
||||
val channelId = ContentUris.parseId(channelUri)
|
||||
prefs.edit().putLong(channelKey, channelId).apply()
|
||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||
storeChannelLogo(context, channelId)
|
||||
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
||||
|
||||
return channelId
|
||||
}
|
||||
|
||||
private fun getChannelKey(displayName: String): String {
|
||||
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
|
||||
}
|
||||
|
||||
private fun upsertPreviewProgram(
|
||||
context: Context,
|
||||
channelId: Long,
|
||||
item: JSONObject,
|
||||
previousProgramId: Long,
|
||||
weight: Int
|
||||
): Long {
|
||||
val providerId = item.optString("id")
|
||||
val imageUrl = item.optString("imageUrl")
|
||||
|
||||
val builder = PreviewProgram.Builder()
|
||||
.setChannelId(channelId)
|
||||
.setType(programTypeFor(item.optString("itemType")))
|
||||
.setTitle(item.optString("title"))
|
||||
.setInternalProviderId(providerId)
|
||||
.setContentId(providerId)
|
||||
.setIntentUri(buildIntentUri(context, item.optString("playRoute").ifBlank { item.optString("route") }))
|
||||
.setWeight(weight)
|
||||
.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
|
||||
|
||||
item.optString("subtitle").takeIf { it.isNotBlank() }?.let {
|
||||
builder.setDescription(it)
|
||||
}
|
||||
|
||||
// Per Android docs: use unique URIs for all images to avoid stale cache
|
||||
imageUrl.takeIf { it.isNotBlank() }?.let {
|
||||
val uniqueImageUrl = appendCacheBuster(it)
|
||||
val imageUri = Uri.parse(uniqueImageUrl)
|
||||
builder.setPosterArtUri(imageUri)
|
||||
builder.setThumbnailUri(imageUri)
|
||||
}
|
||||
|
||||
val contentValues = builder.build().toContentValues()
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
if (previousProgramId > 0L) {
|
||||
try {
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||
contentValues,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
if (updatedRows > 0) {
|
||||
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
||||
return previousProgramId
|
||||
}
|
||||
|
||||
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
|
||||
}
|
||||
}
|
||||
|
||||
val insertedUri = try {
|
||||
contentResolver.insert(
|
||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||
contentValues
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
|
||||
null
|
||||
} ?: return -1L
|
||||
|
||||
val programId = ContentUris.parseId(insertedUri)
|
||||
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
||||
return programId
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a stable cache key derived from the image URL.
|
||||
* The Jellyfin image URLs already include a `tag=` query param (etag)
|
||||
* that changes whenever the image content changes, so a deterministic
|
||||
* hash of the URL is sufficient — the param only changes when the URL
|
||||
* (and therefore the image) actually changes, avoiding unnecessary
|
||||
* re-downloads on every sync.
|
||||
*/
|
||||
private fun appendCacheBuster(imageUrl: String): String {
|
||||
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
|
||||
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
|
||||
val separator = if (imageUrl.contains("?")) "&" else "?"
|
||||
return "$imageUrl${separator}_v=$hash"
|
||||
}
|
||||
|
||||
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(deepLink)
|
||||
`package` = context.packageName
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
|
||||
return Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))
|
||||
}
|
||||
|
||||
private fun programTypeFor(itemType: String): Int {
|
||||
return when (itemType) {
|
||||
"Movie" -> TvContractCompat.PreviewPrograms.TYPE_MOVIE
|
||||
"Episode" -> TvContractCompat.PreviewPrograms.TYPE_TV_EPISODE
|
||||
"Series" -> TvContractCompat.PreviewPrograms.TYPE_TV_SERIES
|
||||
else -> TvContractCompat.PreviewPrograms.TYPE_CLIP
|
||||
}
|
||||
}
|
||||
|
||||
private fun storeChannelLogo(context: Context, channelId: Long) {
|
||||
val bitmap = applicationIconBitmap(context) ?: return
|
||||
try {
|
||||
val outputStream = context.contentResolver.openOutputStream(
|
||||
TvContractCompat.buildChannelLogoUri(channelId)
|
||||
) ?: return
|
||||
|
||||
outputStream.use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
stream.flush()
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applicationIconBitmap(context: Context): Bitmap? {
|
||||
val drawable = try {
|
||||
context.packageManager.getApplicationIcon(context.packageName)
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
Log.w(TAG, "Unable to load application icon", error)
|
||||
return null
|
||||
}
|
||||
|
||||
return drawable.toBitmap()
|
||||
}
|
||||
|
||||
private fun Drawable.toBitmap(): Bitmap {
|
||||
if (this is BitmapDrawable && bitmap != null) {
|
||||
return bitmap
|
||||
}
|
||||
|
||||
val width = intrinsicWidth.takeIf { it > 0 } ?: 256
|
||||
val height = intrinsicHeight.takeIf { it > 0 } ?: 256
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
setBounds(0, 0, canvas.width, canvas.height)
|
||||
draw(canvas)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
|
||||
return preferences(context).getLong(getChannelKey(displayName), -1L)
|
||||
}
|
||||
|
||||
private fun preferences(context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
private fun logProviderState(context: Context, channelId: Long) {
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
try {
|
||||
contentResolver.query(
|
||||
TvContractCompat.buildChannelUri(channelId),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
|
||||
val packageNameIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_PACKAGE_NAME)
|
||||
val displayNameIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_DISPLAY_NAME)
|
||||
|
||||
val browsable = if (browsableIndex >= 0) cursor.getInt(browsableIndex) else -1
|
||||
val packageName = if (packageNameIndex >= 0) cursor.getString(packageNameIndex) else "unknown"
|
||||
val displayName = if (displayNameIndex >= 0) cursor.getString(displayNameIndex) else "unknown"
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"logProviderState(): channelId=$channelId exists=true browsable=$browsable packageName=$packageName displayName=$displayName"
|
||||
)
|
||||
} else {
|
||||
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
||||
}
|
||||
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
||||
} catch (error: SecurityException) {
|
||||
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package expo.modules.tvrecommendations
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ContentUris
|
||||
import android.util.Log
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
|
||||
class TvRecommendationsReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
|
||||
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
||||
TvRecommendationsPublisher.refreshFromCache(context)
|
||||
}
|
||||
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
|
||||
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
|
||||
if (programId > 0L) {
|
||||
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
|
||||
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
modules/tv-recommendations/expo-module.config.json
Normal file
8
modules/tv-recommendations/expo-module.config.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "tv-recommendations",
|
||||
"version": "1.0.0",
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.tvrecommendations.TvRecommendationsModule"]
|
||||
}
|
||||
}
|
||||
1
modules/tv-recommendations/index.ts
Normal file
1
modules/tv-recommendations/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface TvRecommendationsModuleType {
|
||||
syncRecommendations(json: string): boolean;
|
||||
clearRecommendations(): boolean;
|
||||
refreshRecommendations(): boolean;
|
||||
}
|
||||
26
modules/tv-recommendations/src/TvRecommendationsModule.ts
Normal file
26
modules/tv-recommendations/src/TvRecommendationsModule.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import { Platform } from "react-native";
|
||||
import type { TvRecommendationsModuleType } from "./TvRecommendations.types";
|
||||
|
||||
let TvRecommendationsModule: TvRecommendationsModuleType | null = null;
|
||||
|
||||
if (Platform.OS === "android" && Platform.isTV) {
|
||||
try {
|
||||
TvRecommendationsModule =
|
||||
requireNativeModule<TvRecommendationsModuleType>("TvRecommendations");
|
||||
} catch {
|
||||
TvRecommendationsModule = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function syncTvRecommendations(json: string): boolean {
|
||||
return TvRecommendationsModule?.syncRecommendations(json) ?? false;
|
||||
}
|
||||
|
||||
export function clearTvRecommendations(): boolean {
|
||||
return TvRecommendationsModule?.clearRecommendations() ?? false;
|
||||
}
|
||||
|
||||
export function refreshTvRecommendations(): boolean {
|
||||
return TvRecommendationsModule?.refreshRecommendations() ?? false;
|
||||
}
|
||||
6
modules/tv-recommendations/src/index.ts
Normal file
6
modules/tv-recommendations/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type { TvRecommendationsModuleType } from "./TvRecommendations.types";
|
||||
export {
|
||||
clearTvRecommendations,
|
||||
refreshTvRecommendations,
|
||||
syncTvRecommendations,
|
||||
} from "./TvRecommendationsModule";
|
||||
6
modules/tv-search/expo-module.config.json
Normal file
6
modules/tv-search/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["TvSearchModule"]
|
||||
}
|
||||
}
|
||||
2
modules/tv-search/index.ts
Normal file
2
modules/tv-search/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as TvSearchView } from "./src/TvSearchView";
|
||||
export * from "./src/TvSearchView.types";
|
||||
22
modules/tv-search/ios/TvSearch.podspec
Normal file
22
modules/tv-search/ios/TvSearch.podspec
Normal file
@@ -0,0 +1,22 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'TvSearch'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Native tvOS search field with text change events'
|
||||
s.description = 'Hosts SwiftUI .searchable inside a UIHostingController so React Native can render its own results grid while using the native tvOS search bar and grid keyboard.'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = {
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift}"
|
||||
end
|
||||
15
modules/tv-search/ios/TvSearchModule.swift
Normal file
15
modules/tv-search/ios/TvSearchModule.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class TvSearchModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("TvSearchModule")
|
||||
|
||||
View(TvSearchView.self) {
|
||||
Events("onChangeText")
|
||||
|
||||
Prop("placeholder") { (view: TvSearchView, value: String?) in
|
||||
view.setPlaceholder(value ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
206
modules/tv-search/ios/TvSearchView.swift
Normal file
206
modules/tv-search/ios/TvSearchView.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
import ExpoModulesCore
|
||||
import SwiftUI
|
||||
|
||||
// React Native tvOS notification names for controlling gesture handler behavior.
|
||||
// These match the constants in RCTTVRemoteHandler.h and are what make keyboard
|
||||
// input actually reach the native search field on tvOS.
|
||||
private let RCTTVDisableGestureHandlersCancelTouchesNotification = Notification.Name(
|
||||
"RCTTVDisableGestureHandlersCancelTouchesNotification")
|
||||
private let RCTTVEnableGestureHandlersCancelTouchesNotification = Notification.Name(
|
||||
"RCTTVEnableGestureHandlersCancelTouchesNotification")
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
/// Holds the search state. ObservableObject so we can update placeholder/text
|
||||
/// without recreating the SwiftUI hierarchy.
|
||||
class TvSearchViewModel: ObservableObject {
|
||||
@Published var searchText: String = ""
|
||||
@Published var placeholder: String = "Search..."
|
||||
@Published var accentColor: Color = .white
|
||||
var onSearch: ((String) -> Void)?
|
||||
}
|
||||
|
||||
/// SwiftUI content hosting `.searchable`. This mirrors expo-tvos-search's
|
||||
/// structure — `.searchable` attached inside a `NavigationView` (REQUIRED:
|
||||
/// `.searchable` only renders a search bar in a navigation context) — but with
|
||||
/// the results grid REMOVED. The body is just transparent filler so the search
|
||||
/// field + native grid keyboard render; results are drawn by React Native
|
||||
/// below this native view instead.
|
||||
struct TvSearchContentView: View {
|
||||
@ObservedObject var viewModel: TvSearchViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
// Transparent filler gives `.searchable` something to attach to and
|
||||
// lets the native search bar/keyboard own the space.
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.searchable(text: $viewModel.searchText, prompt: viewModel.placeholder)
|
||||
.onChange(of: viewModel.searchText) { newValue in
|
||||
viewModel.onSearch?(newValue)
|
||||
}
|
||||
}
|
||||
.tint(viewModel.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
class TvSearchView: ExpoView {
|
||||
private var hostingController: UIHostingController<TvSearchContentView>?
|
||||
private let viewModel = TvSearchViewModel()
|
||||
private var gestureHandlersDisabled = false
|
||||
private var disabledGestureRecognizers: [UIGestureRecognizer] = []
|
||||
|
||||
let onChangeText = EventDispatcher()
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupView()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
hostingController?.willMove(toParent: nil)
|
||||
hostingController?.removeFromParent()
|
||||
#if !targetEnvironment(simulator)
|
||||
enableParentGestureRecognizers()
|
||||
#endif
|
||||
if gestureHandlersDisabled {
|
||||
NotificationCenter.default.post(
|
||||
name: RCTTVEnableGestureHandlersCancelTouchesNotification, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func setPlaceholder(_ value: String) {
|
||||
viewModel.placeholder = value
|
||||
}
|
||||
|
||||
private func setupView() {
|
||||
viewModel.onSearch = { [weak self] query in
|
||||
self?.onChangeText(["text": query])
|
||||
}
|
||||
|
||||
let controller = UIHostingController(rootView: TvSearchContentView(viewModel: viewModel))
|
||||
controller.view.backgroundColor = .clear
|
||||
hostingController = controller
|
||||
|
||||
addSubview(controller.view)
|
||||
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
controller.view.topAnchor.constraint(equalTo: topAnchor),
|
||||
controller.view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
controller.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
|
||||
if let parentVC = parentViewController() {
|
||||
parentVC.addChild(controller)
|
||||
controller.didMove(toParent: parentVC)
|
||||
}
|
||||
|
||||
// Detect when the search keyboard becomes active so we can release RN's
|
||||
// remote gesture handling (otherwise keystrokes never reach the field).
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleTextFieldDidBeginEditing),
|
||||
name: UITextField.textDidBeginEditingNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleTextFieldDidEndEditing),
|
||||
name: UITextField.textDidEndEditingNotification, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - View controller containment
|
||||
|
||||
/// SwiftUI needs proper appearance lifecycle events for `.searchable` to
|
||||
/// register with tvOS's focus system, so we manage child VC containment as
|
||||
/// the view enters/leaves the window.
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
guard let controller = hostingController else { return }
|
||||
if window != nil {
|
||||
if controller.parent == nil, let parentVC = parentViewController() {
|
||||
parentVC.addChild(controller)
|
||||
controller.didMove(toParent: parentVC)
|
||||
}
|
||||
} else {
|
||||
controller.willMove(toParent: nil)
|
||||
controller.removeFromParent()
|
||||
}
|
||||
}
|
||||
|
||||
private func parentViewController() -> UIViewController? {
|
||||
var responder: UIResponder? = self
|
||||
while let next = responder?.next {
|
||||
if let vc = next as? UIViewController { return vc }
|
||||
responder = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Keyboard / gesture handling
|
||||
|
||||
@objc private func handleTextFieldDidBeginEditing(_ notification: Notification) {
|
||||
guard let textField = notification.object as? UITextField,
|
||||
let hostingView = hostingController?.view,
|
||||
textField.isDescendant(of: hostingView)
|
||||
else { return }
|
||||
|
||||
guard !gestureHandlersDisabled else { return }
|
||||
gestureHandlersDisabled = true
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: RCTTVDisableGestureHandlersCancelTouchesNotification, object: nil)
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
disableParentGestureRecognizers()
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc private func handleTextFieldDidEndEditing(_ notification: Notification) {
|
||||
guard let textField = notification.object as? UITextField,
|
||||
let hostingView = hostingController?.view,
|
||||
textField.isDescendant(of: hostingView)
|
||||
else { return }
|
||||
|
||||
guard gestureHandlersDisabled else { return }
|
||||
gestureHandlersDisabled = false
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
enableParentGestureRecognizers()
|
||||
#endif
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: RCTTVEnableGestureHandlersCancelTouchesNotification, object: nil)
|
||||
}
|
||||
|
||||
private func disableParentGestureRecognizers() {
|
||||
disabledGestureRecognizers.removeAll()
|
||||
var currentView: UIView? = superview
|
||||
while let view = currentView {
|
||||
for recognizer in view.gestureRecognizers ?? [] {
|
||||
let isTapOrPress =
|
||||
recognizer is UITapGestureRecognizer || recognizer is UILongPressGestureRecognizer
|
||||
if isTapOrPress && recognizer.isEnabled {
|
||||
recognizer.isEnabled = false
|
||||
disabledGestureRecognizers.append(recognizer)
|
||||
}
|
||||
}
|
||||
currentView = view.superview
|
||||
}
|
||||
}
|
||||
|
||||
private func enableParentGestureRecognizers() {
|
||||
for recognizer in disabledGestureRecognizers {
|
||||
recognizer.isEnabled = true
|
||||
}
|
||||
disabledGestureRecognizers.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
// Fallback for non-tvOS platforms (iOS).
|
||||
class TvSearchView: ExpoView {
|
||||
let onChangeText = EventDispatcher()
|
||||
func setPlaceholder(_ value: String) {}
|
||||
}
|
||||
|
||||
#endif
|
||||
10
modules/tv-search/package.json
Normal file
10
modules/tv-search/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "tv-search",
|
||||
"version": "0.1.0",
|
||||
"description": "Native tvOS search field (SwiftUI .searchable) emitting typed text to React Native",
|
||||
"main": "index.ts",
|
||||
"platforms": [
|
||||
"apple"
|
||||
],
|
||||
"devDependencies": {}
|
||||
}
|
||||
29
modules/tv-search/src/TvSearchView.tsx
Normal file
29
modules/tv-search/src/TvSearchView.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { requireNativeView } from "expo";
|
||||
import * as React from "react";
|
||||
import type { View } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import type { TvSearchViewProps } from "./TvSearchView.types";
|
||||
|
||||
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
|
||||
// On Android the component is never rendered, but we must avoid calling
|
||||
// `requireNativeView` at module-scope because it would crash on import.
|
||||
const NativeView: React.ComponentType<
|
||||
TvSearchViewProps & React.RefAttributes<View>
|
||||
> =
|
||||
Platform.OS === "ios"
|
||||
? requireNativeView("TvSearchModule")
|
||||
: ((() => null) as any);
|
||||
|
||||
/**
|
||||
* Forwards its ref to the underlying native view so it can be used as a
|
||||
* `TVFocusGuideView` `destinations` target for routing focus into the native
|
||||
* search bar.
|
||||
*/
|
||||
const TvSearchView = React.forwardRef<View, TvSearchViewProps>((props, ref) => {
|
||||
return <NativeView ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
TvSearchView.displayName = "TvSearchView";
|
||||
|
||||
export default TvSearchView;
|
||||
12
modules/tv-search/src/TvSearchView.types.ts
Normal file
12
modules/tv-search/src/TvSearchView.types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ViewProps } from "react-native";
|
||||
|
||||
export interface TvSearchTextChangeEvent {
|
||||
nativeEvent: { text: string };
|
||||
}
|
||||
|
||||
export interface TvSearchViewProps extends ViewProps {
|
||||
/** Placeholder shown in the native search bar. */
|
||||
placeholder?: string;
|
||||
/** Fired as the user types in the native search bar. */
|
||||
onChangeText?: (event: TvSearchTextChangeEvent) => void;
|
||||
}
|
||||
8
modules/tv-user-profile/expo-module.config.json
Normal file
8
modules/tv-user-profile/expo-module.config.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "tv-user-profile",
|
||||
"version": "1.0.0",
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["TvUserProfileModule"]
|
||||
}
|
||||
}
|
||||
84
modules/tv-user-profile/index.ts
Normal file
84
modules/tv-user-profile/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { EventSubscription } from "expo-modules-core";
|
||||
import { Platform, requireNativeModule } from "expo-modules-core";
|
||||
|
||||
interface TvUserProfileModuleEvents {
|
||||
onProfileChange: (event: { profileId: string | null }) => void;
|
||||
}
|
||||
|
||||
interface TvUserProfileModuleType {
|
||||
getCurrentProfileId(): string | null;
|
||||
isProfileSwitchingSupported(): boolean;
|
||||
addListener<K extends keyof TvUserProfileModuleEvents>(
|
||||
eventName: K,
|
||||
listener: TvUserProfileModuleEvents[K],
|
||||
): EventSubscription;
|
||||
}
|
||||
|
||||
// Only load the native module on Apple platforms
|
||||
const TvUserProfileModule: TvUserProfileModuleType | null =
|
||||
Platform.OS === "ios"
|
||||
? requireNativeModule<TvUserProfileModuleType>("TvUserProfile")
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Get the current tvOS profile identifier.
|
||||
* Returns null on non-tvOS platforms or if no profile is active.
|
||||
*/
|
||||
export function getCurrentProfileId(): string | null {
|
||||
if (!TvUserProfileModule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return TvUserProfileModule.getCurrentProfileId() ?? null;
|
||||
} catch (error) {
|
||||
console.error("[TvUserProfile] Error getting profile ID:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tvOS profile switching is supported on this device.
|
||||
* Returns true only on tvOS.
|
||||
*/
|
||||
export function isProfileSwitchingSupported(): boolean {
|
||||
if (!TvUserProfileModule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return TvUserProfileModule.isProfileSwitchingSupported();
|
||||
} catch (error) {
|
||||
console.error("[TvUserProfile] Error checking profile support:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to profile change events.
|
||||
* The callback receives the new profile ID (or null if no profile).
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export function addProfileChangeListener(
|
||||
callback: (profileId: string | null) => void,
|
||||
): () => void {
|
||||
if (!TvUserProfileModule) {
|
||||
// Return no-op unsubscribe on unsupported platforms
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const subscription = TvUserProfileModule.addListener(
|
||||
"onProfileChange",
|
||||
(event) => {
|
||||
callback(event.profileId);
|
||||
},
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}
|
||||
|
||||
export default {
|
||||
getCurrentProfileId,
|
||||
isProfileSwitchingSupported,
|
||||
addProfileChangeListener,
|
||||
};
|
||||
23
modules/tv-user-profile/ios/TvUserProfile.podspec
Normal file
23
modules/tv-user-profile/ios/TvUserProfile.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'TvUserProfile'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'tvOS User Profile Management for Expo'
|
||||
s.description = 'Native tvOS module to get current user profile and listen for profile changes using TVUserManager'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = { :ios => '15.6', :tvos => '15.0' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
# TVServices framework is only available on tvOS
|
||||
s.tvos.frameworks = 'TVServices'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
82
modules/tv-user-profile/ios/TvUserProfileModule.swift
Normal file
82
modules/tv-user-profile/ios/TvUserProfileModule.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
import ExpoModulesCore
|
||||
#if os(tvOS)
|
||||
import TVServices
|
||||
#endif
|
||||
|
||||
public class TvUserProfileModule: Module {
|
||||
#if os(tvOS)
|
||||
private let userManager = TVUserManager()
|
||||
private var profileObservation: NSKeyValueObservation?
|
||||
#endif
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("TvUserProfile")
|
||||
|
||||
// Define event that can be sent to JavaScript
|
||||
Events("onProfileChange")
|
||||
|
||||
// Get current tvOS profile identifier
|
||||
Function("getCurrentProfileId") { () -> String? in
|
||||
#if os(tvOS)
|
||||
let identifier = self.userManager.currentUserIdentifier
|
||||
print("[TvUserProfile] Current profile ID: \(identifier ?? "nil")")
|
||||
return identifier
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
// Check if running on tvOS with profile support
|
||||
Function("isProfileSwitchingSupported") { () -> Bool in
|
||||
#if os(tvOS)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
OnCreate {
|
||||
#if os(tvOS)
|
||||
self.setupProfileObserver()
|
||||
#endif
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
#if os(tvOS)
|
||||
self.profileObservation?.invalidate()
|
||||
self.profileObservation = nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private func setupProfileObserver() {
|
||||
// Debug: Print all available info about TVUserManager
|
||||
print("[TvUserProfile] TVUserManager created")
|
||||
print("[TvUserProfile] currentUserIdentifier: \(userManager.currentUserIdentifier ?? "nil")")
|
||||
if #available(tvOS 16.0, *) {
|
||||
print("[TvUserProfile] shouldStorePreferencesForCurrentUser: \(userManager.shouldStorePreferencesForCurrentUser)")
|
||||
}
|
||||
|
||||
// Set up KVO observation on currentUserIdentifier
|
||||
profileObservation = userManager.observe(\.currentUserIdentifier, options: [.new, .old, .initial]) { [weak self] manager, change in
|
||||
guard let self = self else { return }
|
||||
|
||||
let newProfileId = change.newValue ?? nil
|
||||
let oldProfileId = change.oldValue ?? nil
|
||||
|
||||
print("[TvUserProfile] KVO fired - old: \(oldProfileId ?? "nil"), new: \(newProfileId ?? "nil")")
|
||||
|
||||
// Only send event if the profile actually changed
|
||||
if newProfileId != oldProfileId {
|
||||
print("[TvUserProfile] Profile changed from \(oldProfileId ?? "nil") to \(newProfileId ?? "nil")")
|
||||
self.sendEvent("onProfileChange", [
|
||||
"profileId": newProfileId as Any
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
print("[TvUserProfile] Profile observer set up successfully")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos"],
|
||||
"ios": {
|
||||
"modules": ["VlcPlayer4Module"],
|
||||
"appDelegateSubscribers": ["AppLifecycleDelegate"]
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
protocol SimpleAppLifecycleListener {
|
||||
func applicationDidEnterBackground() -> Void
|
||||
func applicationDidEnterForeground() -> Void
|
||||
}
|
||||
|
||||
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
|
||||
public func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// The app has become active.
|
||||
}
|
||||
|
||||
public func applicationWillResignActive(_ application: UIApplication) {
|
||||
// The app is about to become inactive.
|
||||
}
|
||||
|
||||
public func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
VLCManager.shared.listeners.forEach { listener in
|
||||
listener.applicationDidEnterBackground()
|
||||
}
|
||||
}
|
||||
|
||||
public func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
VLCManager.shared.listeners.forEach { listener in
|
||||
listener.applicationDidEnterForeground()
|
||||
}
|
||||
}
|
||||
|
||||
public func applicationWillTerminate(_ application: UIApplication) {
|
||||
// The app is about to terminate.
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
class VLCManager {
|
||||
static let shared = VLCManager()
|
||||
var listeners: [SimpleAppLifecycleListener] = []
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'VlcPlayer4'
|
||||
s.version = '4.0.0a10'
|
||||
s.summary = 'A sample project summary'
|
||||
s.description = 'A sample project description'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = { :ios => '13.4', :tvos => '16' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.ios.dependency 'VLCKit', s.version
|
||||
s.tvos.dependency 'VLCKit', s.version
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -1,71 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class VlcPlayer4Module: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("VlcPlayer4")
|
||||
View(VlcPlayer4View.self) {
|
||||
Prop("source") { (view: VlcPlayer4View, source: [String: Any]) in
|
||||
view.setSource(source)
|
||||
}
|
||||
|
||||
Prop("paused") { (view: VlcPlayer4View, paused: Bool) in
|
||||
if paused {
|
||||
view.pause()
|
||||
} else {
|
||||
view.play()
|
||||
}
|
||||
}
|
||||
|
||||
Events(
|
||||
"onPlaybackStateChanged",
|
||||
"onVideoStateChange",
|
||||
"onVideoLoadStart",
|
||||
"onVideoLoadEnd",
|
||||
"onVideoProgress",
|
||||
"onVideoError",
|
||||
"onPipStarted"
|
||||
)
|
||||
|
||||
AsyncFunction("startPictureInPicture") { (view: VlcPlayer4View) in
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("play") { (view: VlcPlayer4View) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { (view: VlcPlayer4View) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("stop") { (view: VlcPlayer4View) in
|
||||
view.stop()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { (view: VlcPlayer4View, time: Int32) in
|
||||
view.seekTo(time)
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { (view: VlcPlayer4View, trackIndex: Int) in
|
||||
view.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getAudioTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in
|
||||
return view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer4View, trackIndex: Int) in
|
||||
view.setSubtitleTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in
|
||||
return view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") {
|
||||
(view: VlcPlayer4View, url: String, name: String) in
|
||||
view.setSubtitleURL(url, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
import VLCKit
|
||||
import os
|
||||
|
||||
public class VLCPlayerView: UIView {
|
||||
func setupView(parent: UIView) {
|
||||
self.backgroundColor = .black
|
||||
self.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
self.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
|
||||
self.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
|
||||
self.topAnchor.constraint(equalTo: parent.topAnchor),
|
||||
self.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
for subview in subviews {
|
||||
subview.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VLCPlayerWrapper: NSObject {
|
||||
private var lastProgressCall = Date().timeIntervalSince1970
|
||||
public var player: VLCMediaPlayer = VLCMediaPlayer()
|
||||
private var updatePlayerState: (() -> Void)?
|
||||
private var updateVideoProgress: (() -> Void)?
|
||||
private var playerView: VLCPlayerView = VLCPlayerView()
|
||||
public weak var pipController: VLCPictureInPictureWindowControlling?
|
||||
|
||||
override public init() {
|
||||
super.init()
|
||||
player.delegate = self
|
||||
player.drawable = self
|
||||
player.scaleFactor = 0
|
||||
}
|
||||
|
||||
public func setup(
|
||||
parent: UIView,
|
||||
updatePlayerState: (() -> Void)?,
|
||||
updateVideoProgress: (() -> Void)?
|
||||
) {
|
||||
self.updatePlayerState = updatePlayerState
|
||||
self.updateVideoProgress = updateVideoProgress
|
||||
|
||||
player.delegate = self
|
||||
parent.addSubview(playerView)
|
||||
playerView.setupView(parent: parent)
|
||||
}
|
||||
|
||||
public func getPlayerView() -> UIView {
|
||||
return playerView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCPictureInPictureDrawable
|
||||
extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
|
||||
public func mediaController() -> (any VLCPictureInPictureMediaControlling)! {
|
||||
return self
|
||||
}
|
||||
|
||||
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
|
||||
{
|
||||
return { [weak self] controller in
|
||||
self?.pipController = controller
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCPictureInPictureMediaControlling
|
||||
extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
|
||||
func mediaTime() -> Int64 {
|
||||
return player.time.value?.int64Value ?? 0
|
||||
}
|
||||
|
||||
func mediaLength() -> Int64 {
|
||||
return player.media?.length.value?.int64Value ?? 0
|
||||
}
|
||||
|
||||
func play() {
|
||||
player.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func seek(by offset: Int64, completion: @escaping () -> Void) {
|
||||
player.jump(withOffset: Int32(offset), completion: completion)
|
||||
}
|
||||
|
||||
func isMediaSeekable() -> Bool {
|
||||
return player.isSeekable
|
||||
}
|
||||
|
||||
func isMediaPlaying() -> Bool {
|
||||
return player.isPlaying
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCDrawable
|
||||
extension VLCPlayerWrapper: VLCDrawable {
|
||||
public func addSubview(_ view: UIView) {
|
||||
playerView.addSubview(view)
|
||||
}
|
||||
|
||||
public func bounds() -> CGRect {
|
||||
return playerView.bounds
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCMediaPlayerDelegate
|
||||
extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let timeNow = Date().timeIntervalSince1970
|
||||
if timeNow - self.lastProgressCall >= 1 {
|
||||
self.lastProgressCall = timeNow
|
||||
self.updateVideoProgress?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.updatePlayerState?()
|
||||
|
||||
guard let pipController = self.pipController else { return }
|
||||
pipController.invalidatePlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class VlcPlayer4View: ExpoView {
|
||||
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayer4View")
|
||||
|
||||
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
|
||||
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
||||
private var isPaused: Bool = false
|
||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
||||
private var startPosition: Int32 = 0
|
||||
private var externalTrack: [String: String]?
|
||||
private var isStopping: Bool = false // Define isStopping here
|
||||
private var externalSubtitles: [[String: String]]?
|
||||
var hasSource = false
|
||||
var initialSeekPerformed = false
|
||||
// A flag variable determinging if we should perform the initial seek. Its either transcoding or offline playback. that makes
|
||||
var shouldPerformInitialSeek: Bool = false
|
||||
|
||||
|
||||
// MARK: - Initialization
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupVLC()
|
||||
setupNotifications()
|
||||
VLCManager.shared.listeners.append(self)
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
private func setupVLC() {
|
||||
vlc.setup(
|
||||
parent: self,
|
||||
updatePlayerState: updatePlayerState,
|
||||
updateVideoProgress: updateVideoProgress
|
||||
)
|
||||
}
|
||||
|
||||
// Workaround: When playing an HLS video for the first time, seeking to a specific time immediately can cause a crash.
|
||||
// To avoid this, we wait until the video has started playing before performing the initial seek.
|
||||
func performInitialSeek() {
|
||||
guard !initialSeekPerformed,
|
||||
startPosition > 0,
|
||||
shouldPerformInitialSeek,
|
||||
vlc.player.isSeekable else { return }
|
||||
|
||||
initialSeekPerformed = true
|
||||
logger.debug("First time update, performing initial seek to \(self.startPosition) seconds")
|
||||
vlc.player.time = VLCTime(int: startPosition * 1000)
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationWillResignActive),
|
||||
name: UIApplication.willResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
func startPictureInPicture() {
|
||||
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
|
||||
self.onPipStarted?(["pipStarted": isStarted])
|
||||
}
|
||||
self.vlc.pipController?.startPictureInPicture()
|
||||
}
|
||||
|
||||
@objc func play() {
|
||||
self.vlc.player.play()
|
||||
self.isPaused = false
|
||||
logger.debug("Play")
|
||||
}
|
||||
|
||||
@objc func pause() {
|
||||
self.vlc.player.pause()
|
||||
self.isPaused = true
|
||||
}
|
||||
|
||||
@objc func seekTo(_ time: Int32) {
|
||||
let wasPlaying = vlc.player.isPlaying
|
||||
if wasPlaying {
|
||||
self.pause()
|
||||
}
|
||||
|
||||
if let duration = vlc.player.media?.length.intValue {
|
||||
logger.debug("Seeking to time: \(time) Video Duration \(duration)")
|
||||
|
||||
// If the specified time is greater than the duration, seek to the end
|
||||
let seekTime = time > duration ? duration - 1000 : time
|
||||
vlc.player.time = VLCTime(int: seekTime)
|
||||
self.updatePlayerState()
|
||||
|
||||
// Let mediaPlayerStateChanged handle play state change
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if wasPlaying {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error("Unable to retrieve video duration")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSource(_ source: [String: Any]) {
|
||||
logger.debug("Setting source...")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.hasSource {
|
||||
return
|
||||
}
|
||||
|
||||
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||
let initOptions: [String] = source["initOptions"] as? [String] ?? []
|
||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||
|
||||
for item in initOptions {
|
||||
let option = item.components(separatedBy: "=")
|
||||
mediaOptions.updateValue(
|
||||
option[1], forKey: option[0].replacingOccurrences(of: "--", with: ""))
|
||||
}
|
||||
|
||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||
logger.error("Invalid or empty URI")
|
||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||
return
|
||||
}
|
||||
|
||||
let autoplay = source["autoplay"] as? Bool ?? false
|
||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||
|
||||
// Set shouldPeformIntial based on isTranscoding and is not a network stream
|
||||
self.shouldPerformInitialSeek = uri.contains("m3u8") || !isNetwork
|
||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
||||
|
||||
let media: VLCMedia!
|
||||
if isNetwork {
|
||||
logger.debug("Loading network file: \(uri)")
|
||||
media = VLCMedia(url: URL(string: uri)!)
|
||||
} else {
|
||||
logger.debug("Loading local file: \(uri)")
|
||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
||||
media = VLCMedia(url: url)
|
||||
} else {
|
||||
media = VLCMedia(path: uri)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Media options: \(mediaOptions)")
|
||||
media.addOptions(mediaOptions)
|
||||
|
||||
self.vlc.player.media = media
|
||||
self.setInitialExternalSubtitles()
|
||||
self.hasSource = true
|
||||
if autoplay {
|
||||
logger.info("Playing...")
|
||||
// The Video is not transcoding so it its safe to seek to the start position.
|
||||
if !self.shouldPerformInitialSeek {
|
||||
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
|
||||
}
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
||||
print("Setting audio track: \(trackIndex)")
|
||||
let track = self.vlc.player.audioTracks[trackIndex]
|
||||
track.isSelectedExclusively = true
|
||||
}
|
||||
|
||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
||||
return vlc.player.audioTracks.enumerated().map {
|
||||
return ["name": $1.trackName, "index": $0]
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
||||
logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
|
||||
if trackIndex == -1 {
|
||||
logger.debug("Disabling all subtitles")
|
||||
for track in self.vlc.player.textTracks {
|
||||
track.isSelected = false
|
||||
}
|
||||
return
|
||||
}
|
||||
let track = self.vlc.player.textTracks[trackIndex]
|
||||
track.isSelectedExclusively = true;
|
||||
logger.debug("Current subtitle track index after setting: \(track.trackName)")
|
||||
}
|
||||
|
||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||
guard let url = URL(string: subtitleURL) else {
|
||||
logger.error("Invalid subtitle URL")
|
||||
return
|
||||
}
|
||||
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
||||
if result == 0 {
|
||||
let internalName = "Track \(self.customSubtitles.count)"
|
||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
||||
logger.debug("Subtitle added with result: \(result) \(internalName)")
|
||||
} else {
|
||||
logger.debug("Failed to add subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
||||
if self.vlc.player.textTracks.count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
|
||||
|
||||
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
|
||||
if let customSubtitle = customSubtitles.first(where: {
|
||||
$0.internalName == track.trackName
|
||||
}) {
|
||||
return ["name": customSubtitle.originalName, "index": index]
|
||||
} else {
|
||||
return ["name": track.trackName, "index": index]
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Subtitle tracks: \(tracks)")
|
||||
return tracks
|
||||
}
|
||||
|
||||
@objc func stop(completion: (() -> Void)? = nil) {
|
||||
logger.debug("Stopping media...")
|
||||
guard !isStopping else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
isStopping = true
|
||||
|
||||
// If we're not on the main thread, dispatch to main thread
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.performStop(completion: completion)
|
||||
}
|
||||
} else {
|
||||
performStop(completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
@objc private func applicationWillResignActive() {
|
||||
|
||||
}
|
||||
|
||||
@objc private func applicationDidBecomeActive() {
|
||||
|
||||
}
|
||||
|
||||
private func setInitialExternalSubtitles() {
|
||||
if let externalSubtitles = self.externalSubtitles {
|
||||
for subtitle in externalSubtitles {
|
||||
if let subtitleName = subtitle["name"],
|
||||
let subtitleURL = subtitle["DeliveryUrl"]
|
||||
{
|
||||
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
|
||||
self.setSubtitleURL(subtitleURL, name: subtitleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performStop(completion: (() -> Void)? = nil) {
|
||||
// Stop the media player
|
||||
vlc.player.stop()
|
||||
|
||||
// Remove observer
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
// Clear the video view
|
||||
vlc.getPlayerView().removeFromSuperview()
|
||||
|
||||
isStopping = false
|
||||
completion?()
|
||||
}
|
||||
|
||||
private func updateVideoProgress() {
|
||||
guard self.vlc.player.media != nil else { return }
|
||||
|
||||
let currentTimeMs = self.vlc.player.time.intValue
|
||||
let durationMs = self.vlc.player.media?.length.intValue ?? 0
|
||||
|
||||
logger.debug("Current time: \(currentTimeMs)")
|
||||
self.onVideoProgress?([
|
||||
"currentTime": currentTimeMs,
|
||||
"duration": durationMs,
|
||||
])
|
||||
}
|
||||
|
||||
private func updatePlayerState() {
|
||||
let player = self.vlc.player
|
||||
if player.isPlaying {
|
||||
performInitialSeek()
|
||||
}
|
||||
self.onVideoStateChange?([
|
||||
"target": self.reactTag ?? NSNull(),
|
||||
"currentTime": player.time.intValue,
|
||||
"duration": player.media?.length.intValue ?? 0,
|
||||
"error": false,
|
||||
"isPlaying": player.isPlaying,
|
||||
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
|
||||
"state": player.state.description,
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Expo Events
|
||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
||||
@objc var onVideoError: RCTDirectEventBlock?
|
||||
@objc var onPipStarted: RCTDirectEventBlock?
|
||||
|
||||
// MARK: - Deinitialization
|
||||
|
||||
deinit {
|
||||
logger.debug("Deinitialization")
|
||||
performStop()
|
||||
VLCManager.shared.listeners.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SimpleAppLifecycleListener
|
||||
extension VlcPlayer4View: SimpleAppLifecycleListener {
|
||||
func applicationDidEnterBackground() {
|
||||
logger.debug("Entering background")
|
||||
}
|
||||
|
||||
func applicationDidEnterForeground() {
|
||||
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
|
||||
if !self.vlc.getPlayerView().isDescendant(of: self) {
|
||||
logger.debug("Player view is missing. Adding back as subview")
|
||||
self.addSubview(self.vlc.getPlayerView())
|
||||
}
|
||||
|
||||
// Current solution to fixing black screen when re-entering application
|
||||
if let videoTrack = self.vlc.player.videoTracks.first(where: { $0.isSelected == true }),
|
||||
!self.vlc.isMediaPlaying()
|
||||
{
|
||||
videoTrack.isSelected = false
|
||||
videoTrack.isSelectedExclusively = true
|
||||
self.vlc.player.play()
|
||||
self.vlc.player.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VLCMediaPlayerState {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .opening: return "Opening"
|
||||
case .buffering: return "Buffering"
|
||||
case .playing: return "Playing"
|
||||
case .paused: return "Paused"
|
||||
case .stopped: return "Stopped"
|
||||
case .error: return "Error"
|
||||
case .stopping: return "Stopping"
|
||||
@unknown default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
|
||||
// It loads the native module object from the JSI or falls back to
|
||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
||||
export default requireNativeModule("VlcPlayer4");
|
||||
@@ -1,2 +0,0 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
@@ -1,38 +0,0 @@
|
||||
package expo.modules.vlcplayer
|
||||
|
||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||
|
||||
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
|
||||
// https://docs.expo.dev/modules/android-lifecycle-listeners/
|
||||
object VLCManager: ReactActivityLifecycleListener {
|
||||
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
|
||||
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
|
||||
// listeners.forEach {
|
||||
// it.onCreate(activity, savedInstanceState)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onResume(activity: Activity?) {
|
||||
// listeners.forEach {
|
||||
// it.onResume(activity)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onPause(activity: Activity?) {
|
||||
// listeners.forEach {
|
||||
// it.onPause(activity)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onUserLeaveHint(activity: Activity?) {
|
||||
// listeners.forEach {
|
||||
// it.onUserLeaveHint(activity)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onDestroy(activity: Activity?) {
|
||||
// listeners.forEach {
|
||||
// it.onDestroy(activity)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package expo.modules.vlcplayer
|
||||
|
||||
import androidx.core.os.bundleOf
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class VlcPlayerModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("VlcPlayer")
|
||||
|
||||
OnActivityEntersForeground {
|
||||
VLCManager.listeners.forEach {
|
||||
it.onResume(appContext.currentActivity)
|
||||
}
|
||||
}
|
||||
|
||||
OnActivityEntersBackground {
|
||||
VLCManager.listeners.forEach {
|
||||
it.onPause(appContext.currentActivity)
|
||||
}
|
||||
}
|
||||
|
||||
View(VlcPlayerView::class) {
|
||||
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
|
||||
view.setSource(source)
|
||||
}
|
||||
|
||||
Prop("paused") { view: VlcPlayerView, paused: Boolean ->
|
||||
if (paused) {
|
||||
view.pause()
|
||||
} else {
|
||||
view.play()
|
||||
}
|
||||
}
|
||||
|
||||
Events(
|
||||
"onPlaybackStateChanged",
|
||||
"onVideoStateChange",
|
||||
"onVideoLoadStart",
|
||||
"onVideoLoadEnd",
|
||||
"onVideoProgress",
|
||||
"onVideoError",
|
||||
"onPipStarted"
|
||||
)
|
||||
|
||||
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("play") { view: VlcPlayerView ->
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { view: VlcPlayerView ->
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("stop") { view: VlcPlayerView ->
|
||||
view.stop()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { view: VlcPlayerView, time: Int ->
|
||||
view.seekTo(time)
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { view: VlcPlayerView, trackIndex: Int ->
|
||||
view.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getAudioTracks") { view: VlcPlayerView ->
|
||||
view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { view: VlcPlayerView, trackIndex: Int ->
|
||||
view.setSubtitleTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getSubtitleTracks") { view: VlcPlayerView ->
|
||||
view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
|
||||
view.setSubtitleURL(url, name)
|
||||
}
|
||||
|
||||
AsyncFunction("setVideoAspectRatio") { view: VlcPlayerView, aspectRatio: String? ->
|
||||
view.setVideoAspectRatio(aspectRatio)
|
||||
}
|
||||
|
||||
AsyncFunction("setVideoScaleFactor") { view: VlcPlayerView, scaleFactor: Float ->
|
||||
view.setVideoScaleFactor(scaleFactor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
package expo.modules.vlcplayer
|
||||
|
||||
import android.R
|
||||
import android.app.Activity
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||
import expo.modules.core.logging.LogHandlers
|
||||
import expo.modules.core.logging.Logger
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
import org.videolan.libvlc.LibVLC
|
||||
import org.videolan.libvlc.Media
|
||||
import org.videolan.libvlc.MediaPlayer
|
||||
import org.videolan.libvlc.interfaces.IMedia
|
||||
import org.videolan.libvlc.util.VLCVideoLayout
|
||||
|
||||
|
||||
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener, ReactActivityLifecycleListener {
|
||||
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
|
||||
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
|
||||
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
|
||||
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
|
||||
|
||||
private var libVLC: LibVLC? = null
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private lateinit var videoLayout: VLCVideoLayout
|
||||
private var isPaused: Boolean = false
|
||||
private var lastReportedState: Int? = null
|
||||
private var lastReportedIsPlaying: Boolean? = null
|
||||
private var media : Media? = null
|
||||
private var timeLeft: Long? = null
|
||||
|
||||
private val onVideoProgress by EventDispatcher()
|
||||
private val onVideoStateChange by EventDispatcher()
|
||||
private val onVideoLoadEnd by EventDispatcher()
|
||||
private val onPipStarted by EventDispatcher()
|
||||
|
||||
private var startPosition: Int? = 0
|
||||
private var isMediaReady: Boolean = false
|
||||
private var externalTrack: Map<String, String>? = null
|
||||
private var externalSubtitles: List<Map<String, String>>? = null
|
||||
var hasSource: Boolean = false
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val updateInterval = 1000L // 1 second
|
||||
private val updateProgressRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
updateVideoProgress()
|
||||
handler.postDelayed(this, updateInterval)
|
||||
}
|
||||
}
|
||||
private val currentActivity get() = context.findActivity()
|
||||
private val actions: MutableList<RemoteAction> = mutableListOf()
|
||||
private val remoteActionFilter = IntentFilter()
|
||||
private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
|
||||
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
|
||||
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
|
||||
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
PIP_PLAY_PAUSE_ACTION -> {
|
||||
if (isPaused) play() else pause()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
setupPipActions()
|
||||
currentActivity.setPictureInPictureParams(getPipParams()!!)
|
||||
}
|
||||
}
|
||||
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
|
||||
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
|
||||
if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
|
||||
log.debug("Exiting PiP")
|
||||
timeLeft = mediaPlayer?.time
|
||||
pause()
|
||||
|
||||
// Setting the media after reattaching the view allows for a fast video view render
|
||||
if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
mediaPlayer?.media = media
|
||||
mediaPlayer?.play()
|
||||
timeLeft?.let { mediaPlayer?.time = it }
|
||||
mediaPlayer?.pause()
|
||||
|
||||
}
|
||||
}
|
||||
onPipStarted(mapOf(
|
||||
"pipStarted" to info.isInPictureInPictureMode
|
||||
))
|
||||
}
|
||||
|
||||
init {
|
||||
VLCManager.listeners.add(this)
|
||||
setupView()
|
||||
setupPiP()
|
||||
}
|
||||
|
||||
private fun setupView() {
|
||||
log.debug("Setting up view")
|
||||
setBackgroundColor(android.graphics.Color.WHITE)
|
||||
videoLayout = VLCVideoLayout(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
videoLayout.keepScreenOn = true
|
||||
addView(videoLayout)
|
||||
log.debug("View setup complete")
|
||||
}
|
||||
|
||||
private fun setupPiP() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
|
||||
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
|
||||
remoteActionFilter.addAction(PIP_REWIND_ACTION)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
currentActivity.registerReceiver(
|
||||
actionReceiver,
|
||||
remoteActionFilter,
|
||||
Context.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
setupPipActions()
|
||||
currentActivity.apply {
|
||||
setPictureInPictureParams(getPipParams()!!)
|
||||
addOnPictureInPictureModeChangedListener(pipChangeListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun setupPipActions() {
|
||||
actions.clear()
|
||||
actions.addAll(
|
||||
listOf(
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, R.drawable.ic_media_rew),
|
||||
"Rewind",
|
||||
"Rewind Video",
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
rewindIntent,
|
||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||
)
|
||||
),
|
||||
RemoteAction(
|
||||
if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
|
||||
else Icon.createWithResource(context, R.drawable.ic_media_pause),
|
||||
"Play",
|
||||
"Play Video",
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
if (isPaused) 0 else 1,
|
||||
playPauseIntent,
|
||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||
)
|
||||
),
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, R.drawable.ic_media_ff),
|
||||
"Skip",
|
||||
"Skip Forward",
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
forwardIntent,
|
||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getPipParams(): PictureInPictureParams? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
var builder = PictureInPictureParams.Builder()
|
||||
.setActions(actions)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder = builder.setAutoEnterEnabled(true)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun setSource(source: Map<String, Any>) {
|
||||
log.debug("setting source $source")
|
||||
if (hasSource) {
|
||||
log.debug("Source already set. Ignoring.")
|
||||
return
|
||||
}
|
||||
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
||||
val autoplay = source["autoplay"] as? Boolean ?: false
|
||||
val isNetwork = source["isNetwork"] as? Boolean ?: false
|
||||
externalTrack = source["externalTrack"] as? Map<String, String>
|
||||
externalSubtitles = source["externalSubtitles"] as? List<Map<String, String>>
|
||||
startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0
|
||||
|
||||
val initOptions = source["initOptions"] as? MutableList<String> ?: mutableListOf()
|
||||
initOptions.add("--start-time=$startPosition")
|
||||
|
||||
|
||||
val uri = source["uri"] as? String
|
||||
|
||||
// Handle video load start event
|
||||
// onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null"))
|
||||
|
||||
libVLC = LibVLC(context, initOptions)
|
||||
mediaPlayer = MediaPlayer(libVLC)
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
mediaPlayer?.setEventListener(this)
|
||||
|
||||
log.debug("Loading network file: $uri")
|
||||
media = Media(libVLC, Uri.parse(uri))
|
||||
mediaPlayer?.media = media
|
||||
|
||||
log.debug("Debug: Media options: $mediaOptions")
|
||||
// media.addOptions(mediaOptions)
|
||||
|
||||
// Set initial external subtitles immediately like iOS
|
||||
setInitialExternalSubtitles()
|
||||
|
||||
hasSource = true
|
||||
|
||||
if (autoplay) {
|
||||
log.debug("Playing...")
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
fun startPictureInPicture() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
currentActivity.enterPictureInPictureMode(getPipParams()!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun play() {
|
||||
mediaPlayer?.play()
|
||||
isPaused = false
|
||||
handler.post(updateProgressRunnable) // Start updating progress
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
mediaPlayer?.pause()
|
||||
isPaused = true
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
mediaPlayer?.stop()
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
}
|
||||
|
||||
fun seekTo(time: Int) {
|
||||
mediaPlayer?.let { player ->
|
||||
val wasPlaying = player.isPlaying
|
||||
if (wasPlaying) {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
val duration = player.length.toInt()
|
||||
val seekTime = if (time > duration) duration - 1000 else time
|
||||
player.time = seekTime.toLong()
|
||||
|
||||
if (wasPlaying) {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAudioTrack(trackIndex: Int) {
|
||||
mediaPlayer?.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
fun getAudioTracks(): List<Map<String, Any>>? {
|
||||
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
|
||||
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
|
||||
|
||||
return trackDescriptions.map { trackDescription ->
|
||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(trackIndex: Int) {
|
||||
mediaPlayer?.setSpuTrack(trackIndex)
|
||||
}
|
||||
|
||||
// fun getSubtitleTracks(): List<Map<String, Any>>? {
|
||||
// return mediaPlayer?.getSpuTracks()?.map { trackDescription ->
|
||||
// mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
||||
// }
|
||||
// }
|
||||
|
||||
fun getSubtitleTracks(): List<Map<String, Any>>? {
|
||||
val subtitleTracks = mediaPlayer?.spuTracks?.map { trackDescription ->
|
||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
||||
}
|
||||
|
||||
// Debug statement to print the result
|
||||
log.debug("Subtitle Tracks: $subtitleTracks")
|
||||
|
||||
return subtitleTracks
|
||||
}
|
||||
|
||||
fun setSubtitleURL(subtitleURL: String, name: String) {
|
||||
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
|
||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
||||
}
|
||||
|
||||
fun setVideoAspectRatio(aspectRatio: String?) {
|
||||
log.debug("Setting video aspect ratio: $aspectRatio")
|
||||
mediaPlayer?.aspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
fun setVideoScaleFactor(scaleFactor: Float) {
|
||||
log.debug("Setting video scale factor: $scaleFactor")
|
||||
mediaPlayer?.scale = scaleFactor
|
||||
}
|
||||
|
||||
private fun setInitialExternalSubtitles() {
|
||||
externalSubtitles?.let { subtitles ->
|
||||
for (subtitle in subtitles) {
|
||||
val subtitleName = subtitle["name"]
|
||||
val subtitleURL = subtitle["DeliveryUrl"]
|
||||
if (!subtitleName.isNullOrEmpty() && !subtitleURL.isNullOrEmpty()) {
|
||||
log.debug("Setting external subtitle: $subtitleName $subtitleURL")
|
||||
setSubtitleURL(subtitleURL, subtitleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
log.debug("onDetachedFromWindow")
|
||||
super.onDetachedFromWindow()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
currentActivity.setPictureInPictureParams(
|
||||
PictureInPictureParams.Builder()
|
||||
.setAutoEnterEnabled(false)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
currentActivity.unregisterReceiver(actionReceiver)
|
||||
}
|
||||
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
|
||||
VLCManager.listeners.clear()
|
||||
|
||||
mediaPlayer?.stop()
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
|
||||
media?.release()
|
||||
mediaPlayer?.release()
|
||||
libVLC?.release()
|
||||
mediaPlayer = null
|
||||
media = null
|
||||
libVLC = null
|
||||
}
|
||||
|
||||
override fun onEvent(event: MediaPlayer.Event) {
|
||||
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
|
||||
when (event.type) {
|
||||
MediaPlayer.Event.Playing,
|
||||
MediaPlayer.Event.Paused,
|
||||
MediaPlayer.Event.Stopped,
|
||||
MediaPlayer.Event.Buffering,
|
||||
MediaPlayer.Event.EndReached,
|
||||
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
|
||||
MediaPlayer.Event.TimeChanged -> {
|
||||
// Do nothing here, as we are updating progress every 1 second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePlayerState(event: MediaPlayer.Event) {
|
||||
val player = mediaPlayer ?: return
|
||||
val currentState = event.type
|
||||
|
||||
val stateInfo = mutableMapOf<String, Any>(
|
||||
"target" to "null", // Replace with actual target if needed
|
||||
"currentTime" to player.time.toInt(),
|
||||
"duration" to (player.media?.duration?.toInt() ?: 0),
|
||||
"error" to false,
|
||||
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
|
||||
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
|
||||
)
|
||||
|
||||
// Todo: make enum - string to prevent this when statement from becoming exhaustive
|
||||
when (currentState) {
|
||||
MediaPlayer.Event.Playing ->
|
||||
stateInfo["state"] = "Playing"
|
||||
MediaPlayer.Event.Paused ->
|
||||
stateInfo["state"] = "Paused"
|
||||
MediaPlayer.Event.Buffering ->
|
||||
stateInfo["state"] = "Buffering"
|
||||
MediaPlayer.Event.EncounteredError -> {
|
||||
stateInfo["state"] = "Error"
|
||||
onVideoLoadEnd(stateInfo);
|
||||
}
|
||||
MediaPlayer.Event.Opening ->
|
||||
stateInfo["state"] = "Opening"
|
||||
}
|
||||
|
||||
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
|
||||
lastReportedState = currentState
|
||||
lastReportedIsPlaying = player.isPlaying
|
||||
onVideoStateChange(stateInfo)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun updateVideoProgress() {
|
||||
val player = mediaPlayer ?: return
|
||||
|
||||
val currentTimeMs = player.time.toInt()
|
||||
val durationMs = player.media?.duration?.toInt() ?: 0
|
||||
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
|
||||
// Set subtitle URL if available
|
||||
if (player.isPlaying && !isMediaReady) {
|
||||
isMediaReady = true
|
||||
externalTrack?.let {
|
||||
val name = it["name"]
|
||||
val deliveryUrl = it["DeliveryUrl"] ?: ""
|
||||
if (!name.isNullOrEmpty() && !deliveryUrl.isNullOrEmpty()) {
|
||||
setSubtitleURL(deliveryUrl, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
onVideoProgress(mapOf(
|
||||
"currentTime" to currentTimeMs,
|
||||
"duration" to durationMs
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause(activity: Activity?) {
|
||||
log.debug("Pausing activity...")
|
||||
}
|
||||
|
||||
|
||||
override fun onResume(activity: Activity?) {
|
||||
log.debug("Resuming activity...")
|
||||
if (isPaused) play()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Context.findActivity(): androidx.activity.ComponentActivity {
|
||||
var context = this
|
||||
while (context is ContextWrapper) {
|
||||
if (context is androidx.activity.ComponentActivity) return context
|
||||
context = context.baseContext
|
||||
}
|
||||
throw IllegalStateException("Failed to find ComponentActivity")
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos", "android", "web"],
|
||||
"ios": {
|
||||
"modules": ["VlcPlayerModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'VlcPlayer'
|
||||
s.version = '3.6.1b1'
|
||||
s.summary = 'A sample project summary'
|
||||
s.description = 'A sample project description'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.ios.dependency 'MobileVLCKit', s.version
|
||||
s.tvos.dependency 'TVVLCKit', s.version
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -1,84 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class VlcPlayerModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("VlcPlayer")
|
||||
View(VlcPlayerView.self) {
|
||||
Prop("source") { (view: VlcPlayerView, source: [String: Any]) in
|
||||
view.setSource(source)
|
||||
}
|
||||
|
||||
Prop("paused") { (view: VlcPlayerView, paused: Bool) in
|
||||
if paused {
|
||||
view.pause()
|
||||
} else {
|
||||
view.play()
|
||||
}
|
||||
}
|
||||
|
||||
Prop("nowPlayingMetadata") { (view: VlcPlayerView, metadata: [String: String]?) in
|
||||
if let metadata = metadata {
|
||||
view.setNowPlayingMetadata(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
Events(
|
||||
"onPlaybackStateChanged",
|
||||
"onVideoStateChange",
|
||||
"onVideoLoadStart",
|
||||
"onVideoLoadEnd",
|
||||
"onVideoProgress",
|
||||
"onVideoError",
|
||||
"onPipStarted"
|
||||
)
|
||||
|
||||
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("play") { (view: VlcPlayerView) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { (view: VlcPlayerView) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("stop") { (view: VlcPlayerView) in
|
||||
view.stop()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Int32) in
|
||||
view.seekTo(time)
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
||||
view.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getAudioTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
||||
return view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") { (view: VlcPlayerView, url: String, name: String) in
|
||||
view.setSubtitleURL(url, name: name)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
||||
view.setSubtitleTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("setVideoAspectRatio") { (view: VlcPlayerView, aspectRatio: String?) in
|
||||
view.setVideoAspectRatio(aspectRatio)
|
||||
}
|
||||
|
||||
AsyncFunction("setVideoScaleFactor") { (view: VlcPlayerView, scaleFactor: Float) in
|
||||
view.setVideoScaleFactor(scaleFactor)
|
||||
}
|
||||
|
||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
||||
return view.getSubtitleTracks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
import MediaPlayer
|
||||
import AVFoundation
|
||||
|
||||
#if os(tvOS)
|
||||
import TVVLCKit
|
||||
#else
|
||||
import MobileVLCKit
|
||||
#endif
|
||||
|
||||
class VlcPlayerView: ExpoView {
|
||||
private var mediaPlayer: VLCMediaPlayer?
|
||||
private var videoView: UIView?
|
||||
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
||||
private var isPaused: Bool = false
|
||||
private var currentGeometryCString: [CChar]?
|
||||
private var lastReportedState: VLCMediaPlayerState?
|
||||
private var lastReportedIsPlaying: Bool?
|
||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
||||
private var startPosition: Int32 = 0
|
||||
private var externalSubtitles: [[String: String]]?
|
||||
private var externalTrack: [String: String]?
|
||||
private var progressTimer: DispatchSourceTimer?
|
||||
private var isStopping: Bool = false // Define isStopping here
|
||||
private var lastProgressCall = Date().timeIntervalSince1970
|
||||
var hasSource = false
|
||||
var isTranscoding = false
|
||||
private var initialSeekPerformed: Bool = false
|
||||
private var nowPlayingMetadata: [String: String]?
|
||||
private var artworkImage: UIImage?
|
||||
private var artworkDownloadTask: URLSessionDataTask?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupView()
|
||||
setupNotifications()
|
||||
setupRemoteCommandCenter()
|
||||
setupAudioSession()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupView() {
|
||||
DispatchQueue.main.async {
|
||||
self.backgroundColor = .black
|
||||
self.videoView = UIView()
|
||||
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
if let videoView = self.videoView {
|
||||
self.addSubview(videoView)
|
||||
NSLayoutConstraint.activate([
|
||||
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
videoView.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationWillResignActive),
|
||||
name: UIApplication.willResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
|
||||
#if !os(tvOS)
|
||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleAudioSessionInterruption),
|
||||
name: AVAudioSession.interruptionNotification, object: nil)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func setupAudioSession() {
|
||||
#if !os(tvOS)
|
||||
do {
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
try audioSession.setCategory(.playback, mode: .moviePlayback, options: [])
|
||||
try audioSession.setActive(true)
|
||||
print("Audio session configured for media controls")
|
||||
} catch {
|
||||
print("Failed to setup audio session: \(error)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func setupRemoteCommandCenter() {
|
||||
#if !os(tvOS)
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
|
||||
// Play command
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
commandCenter.playCommand.addTarget { [weak self] _ in
|
||||
self?.play()
|
||||
return .success
|
||||
}
|
||||
|
||||
// Pause command
|
||||
commandCenter.pauseCommand.isEnabled = true
|
||||
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||
self?.pause()
|
||||
return .success
|
||||
}
|
||||
|
||||
// Toggle play/pause command
|
||||
commandCenter.togglePlayPauseCommand.isEnabled = true
|
||||
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
guard let self = self, let player = self.mediaPlayer else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
if player.isPlaying {
|
||||
self.pause()
|
||||
} else {
|
||||
self.play()
|
||||
}
|
||||
return .success
|
||||
}
|
||||
|
||||
// Seek forward command
|
||||
commandCenter.skipForwardCommand.isEnabled = true
|
||||
commandCenter.skipForwardCommand.preferredIntervals = [15]
|
||||
commandCenter.skipForwardCommand.addTarget { [weak self] event in
|
||||
guard let self = self, let player = self.mediaPlayer else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
|
||||
let currentTime = player.time.intValue
|
||||
self.seekTo(currentTime + Int32(skipInterval * 1000))
|
||||
return .success
|
||||
}
|
||||
|
||||
// Seek backward command
|
||||
commandCenter.skipBackwardCommand.isEnabled = true
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = [15]
|
||||
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
|
||||
guard let self = self, let player = self.mediaPlayer else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
|
||||
let currentTime = player.time.intValue
|
||||
self.seekTo(max(0, currentTime - Int32(skipInterval * 1000)))
|
||||
return .success
|
||||
}
|
||||
|
||||
// Change playback position command (scrubbing)
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
guard let self = self,
|
||||
let event = event as? MPChangePlaybackPositionCommandEvent else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
let positionTime = event.positionTime
|
||||
self.seekTo(Int32(positionTime * 1000))
|
||||
return .success
|
||||
}
|
||||
|
||||
print("Remote command center configured")
|
||||
#endif
|
||||
}
|
||||
|
||||
private func cleanupRemoteCommandCenter() {
|
||||
#if !os(tvOS)
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
|
||||
// Remove all command targets to prevent memory leaks
|
||||
commandCenter.playCommand.removeTarget(nil)
|
||||
commandCenter.pauseCommand.removeTarget(nil)
|
||||
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
||||
commandCenter.skipForwardCommand.removeTarget(nil)
|
||||
commandCenter.skipBackwardCommand.removeTarget(nil)
|
||||
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
||||
|
||||
// Disable commands
|
||||
commandCenter.playCommand.isEnabled = false
|
||||
commandCenter.pauseCommand.isEnabled = false
|
||||
commandCenter.togglePlayPauseCommand.isEnabled = false
|
||||
commandCenter.skipForwardCommand.isEnabled = false
|
||||
commandCenter.skipBackwardCommand.isEnabled = false
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
||||
|
||||
print("Remote command center cleaned up")
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
func startPictureInPicture() {}
|
||||
|
||||
@objc func play() {
|
||||
DispatchQueue.main.async {
|
||||
self.mediaPlayer?.play()
|
||||
self.isPaused = false
|
||||
self.updateNowPlayingInfo()
|
||||
print("Play")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func pause() {
|
||||
DispatchQueue.main.async {
|
||||
self.mediaPlayer?.pause()
|
||||
self.isPaused = true
|
||||
self.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
||||
#if !os(tvOS)
|
||||
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("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("Audio session interruption ended - can resume")
|
||||
// Don't auto-resume - let user manually resume playback
|
||||
} else {
|
||||
print("Audio session interruption ended - should not resume")
|
||||
}
|
||||
}
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc func seekTo(_ time: Int32) {
|
||||
DispatchQueue.main.async {
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
|
||||
let wasPlaying = player.isPlaying
|
||||
if wasPlaying {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
if let duration = player.media?.length.intValue {
|
||||
print("Seeking to time: \(time) Video Duration \(duration)")
|
||||
|
||||
// If the specified time is greater than the duration, seek to the end
|
||||
let seekTime = time > duration ? duration - 1000 : time
|
||||
player.time = VLCTime(int: seekTime)
|
||||
if wasPlaying {
|
||||
player.play()
|
||||
}
|
||||
self.updatePlayerState()
|
||||
self.updateNowPlayingInfo()
|
||||
} else {
|
||||
print("Error: Unable to retrieve video duration")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSource(_ source: [String: Any]) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.hasSource {
|
||||
return
|
||||
}
|
||||
|
||||
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||
|
||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||
print("Error: Invalid or empty URI")
|
||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||
return
|
||||
}
|
||||
|
||||
self.isTranscoding = uri.contains("m3u8")
|
||||
|
||||
if !self.isTranscoding, self.startPosition > 0 {
|
||||
initOptions.append("--start-time=\(self.startPosition)")
|
||||
}
|
||||
|
||||
let autoplay = source["autoplay"] as? Bool ?? false
|
||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||
|
||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
||||
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
|
||||
self.mediaPlayer?.delegate = self
|
||||
self.mediaPlayer?.drawable = self.videoView
|
||||
self.mediaPlayer?.scaleFactor = 0
|
||||
self.initialSeekPerformed = false
|
||||
|
||||
let media: VLCMedia
|
||||
if isNetwork {
|
||||
print("Loading network file: \(uri)")
|
||||
media = VLCMedia(url: URL(string: uri)!)
|
||||
} else {
|
||||
print("Loading local file: \(uri)")
|
||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
||||
media = VLCMedia(url: url)
|
||||
} else {
|
||||
media = VLCMedia(path: uri)
|
||||
}
|
||||
}
|
||||
|
||||
print("Debug: Media options: \(mediaOptions)")
|
||||
media.addOptions(mediaOptions)
|
||||
|
||||
self.mediaPlayer?.media = media
|
||||
self.setInitialExternalSubtitles()
|
||||
self.hasSource = true
|
||||
if autoplay {
|
||||
print("Playing...")
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
||||
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
||||
}
|
||||
|
||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
||||
guard let trackNames = mediaPlayer?.audioTrackNames,
|
||||
let trackIndexes = mediaPlayer?.audioTrackIndexes
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return zip(trackNames, trackIndexes).map { name, index in
|
||||
return ["name": name, "index": index]
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
||||
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
||||
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
||||
print(
|
||||
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
|
||||
)
|
||||
}
|
||||
|
||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||
guard let url = URL(string: subtitleURL) else {
|
||||
print("Error: Invalid subtitle URL")
|
||||
return
|
||||
}
|
||||
|
||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
||||
if let result = result {
|
||||
let internalName = "Track \(self.customSubtitles.count)"
|
||||
print("Subtitle added with result: \(result) \(internalName)")
|
||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
||||
} else {
|
||||
print("Failed to add subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
private func setInitialExternalSubtitles() {
|
||||
if let externalSubtitles = self.externalSubtitles {
|
||||
for subtitle in externalSubtitles {
|
||||
if let subtitleName = subtitle["name"],
|
||||
let subtitleURL = subtitle["DeliveryUrl"]
|
||||
{
|
||||
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
|
||||
self.setSubtitleURL(subtitleURL, name: subtitleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
||||
guard let mediaPlayer = self.mediaPlayer else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let count = mediaPlayer.numberOfSubtitlesTracks
|
||||
print("Debug: Number of subtitle tracks: \(count)")
|
||||
|
||||
guard count > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tracks: [[String: Any]] = []
|
||||
|
||||
if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
||||
{
|
||||
for (index, name) in zip(indexes, names) {
|
||||
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
|
||||
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
|
||||
} else {
|
||||
tracks.append(["name": name, "index": index.intValue])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("Debug: Subtitle tracks: \(tracks)")
|
||||
return tracks
|
||||
}
|
||||
|
||||
@objc func setVideoAspectRatio(_ aspectRatio: String?) {
|
||||
DispatchQueue.main.async {
|
||||
if let aspectRatio = aspectRatio {
|
||||
// Convert String to C string for VLC
|
||||
let cString = strdup(aspectRatio)
|
||||
self.mediaPlayer?.videoAspectRatio = cString
|
||||
} else {
|
||||
// Reset to default (let VLC determine aspect ratio)
|
||||
self.mediaPlayer?.videoAspectRatio = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setVideoScaleFactor(_ scaleFactor: Float) {
|
||||
DispatchQueue.main.async {
|
||||
self.mediaPlayer?.scaleFactor = scaleFactor
|
||||
print("Set video scale factor: \(scaleFactor)")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setNowPlayingMetadata(_ metadata: [String: String]) {
|
||||
// Cancel any existing artwork download to prevent race conditions
|
||||
artworkDownloadTask?.cancel()
|
||||
artworkDownloadTask = nil
|
||||
|
||||
self.nowPlayingMetadata = metadata
|
||||
print("[NowPlaying] Metadata received: \(metadata)")
|
||||
|
||||
// Load artwork asynchronously if provided
|
||||
if let artworkUri = metadata["artworkUri"], let url = URL(string: artworkUri) {
|
||||
print("[NowPlaying] Loading artwork from: \(artworkUri)")
|
||||
artworkDownloadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let error = error as NSError?, error.code == NSURLErrorCancelled {
|
||||
print("[NowPlaying] Artwork download cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
print("[NowPlaying] Artwork loading error: \(error)")
|
||||
DispatchQueue.main.async {
|
||||
self.updateNowPlayingInfo()
|
||||
}
|
||||
} else if let data = data, let image = UIImage(data: data) {
|
||||
print("[NowPlaying] Artwork loaded successfully, size: \(image.size)")
|
||||
self.artworkImage = image
|
||||
DispatchQueue.main.async {
|
||||
self.updateNowPlayingInfo()
|
||||
}
|
||||
} else {
|
||||
print("[NowPlaying] Failed to create image from data")
|
||||
// Update Now Playing info without artwork on failure
|
||||
DispatchQueue.main.async {
|
||||
self.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
artworkDownloadTask?.resume()
|
||||
} else {
|
||||
// No artwork URI provided - update immediately
|
||||
print("[NowPlaying] No artwork URI provided")
|
||||
artworkImage = nil
|
||||
DispatchQueue.main.async {
|
||||
self.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func stop(completion: (() -> Void)? = nil) {
|
||||
guard !isStopping else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
isStopping = true
|
||||
|
||||
// If we're not on the main thread, dispatch to main thread
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.performStop(completion: completion)
|
||||
}
|
||||
} else {
|
||||
performStop(completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
@objc private func applicationWillResignActive() {
|
||||
|
||||
}
|
||||
|
||||
@objc private func applicationDidBecomeActive() {
|
||||
|
||||
}
|
||||
|
||||
private func performStop(completion: (() -> Void)? = nil) {
|
||||
// Stop the media player
|
||||
mediaPlayer?.stop()
|
||||
|
||||
// Cancel any in-flight artwork downloads
|
||||
artworkDownloadTask?.cancel()
|
||||
artworkDownloadTask = nil
|
||||
artworkImage = nil
|
||||
|
||||
// Cleanup remote command center targets
|
||||
cleanupRemoteCommandCenter()
|
||||
|
||||
#if !os(tvOS)
|
||||
// Deactivate audio session to allow other apps to use audio
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
print("Audio session deactivated")
|
||||
} catch {
|
||||
print("Failed to deactivate audio session: \(error)")
|
||||
}
|
||||
|
||||
// Clear Now Playing info
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
#endif
|
||||
|
||||
// Remove observer
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
// Clear the video view
|
||||
videoView?.removeFromSuperview()
|
||||
videoView = nil
|
||||
|
||||
// Release the media player
|
||||
mediaPlayer?.delegate = nil
|
||||
mediaPlayer = nil
|
||||
|
||||
isStopping = false
|
||||
completion?()
|
||||
}
|
||||
|
||||
private func updateVideoProgress() {
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
|
||||
let currentTimeMs = player.time.intValue
|
||||
let durationMs = player.media?.length.intValue ?? 0
|
||||
|
||||
|
||||
print("Debug: Current time: \(currentTimeMs)")
|
||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
||||
if self.isTranscoding, !self.initialSeekPerformed, self.startPosition > 0 {
|
||||
player.time = VLCTime(int: self.startPosition * 1000)
|
||||
self.initialSeekPerformed = true
|
||||
}
|
||||
self.onVideoProgress?([
|
||||
"currentTime": currentTimeMs,
|
||||
"duration": durationMs,
|
||||
])
|
||||
}
|
||||
|
||||
// Update Now Playing info to sync elapsed playback time
|
||||
// iOS needs periodic updates to keep progress indicator in sync
|
||||
DispatchQueue.main.async {
|
||||
self.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNowPlayingInfo() {
|
||||
#if !os(tvOS)
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
|
||||
var nowPlayingInfo = [String: Any]()
|
||||
|
||||
// Playback rate (0.0 = paused, 1.0 = playing at normal speed)
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? player.rate : 0.0
|
||||
|
||||
// Current playback time in seconds
|
||||
let currentTimeSeconds = Double(player.time.intValue) / 1000.0
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTimeSeconds
|
||||
|
||||
// Total duration in seconds
|
||||
if let duration = player.media?.length.intValue {
|
||||
let durationSeconds = Double(duration) / 1000.0
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = durationSeconds
|
||||
}
|
||||
|
||||
// Add metadata if available
|
||||
if let metadata = self.nowPlayingMetadata {
|
||||
if let title = metadata["title"] {
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = title
|
||||
print("[NowPlaying] Setting title: \(title)")
|
||||
}
|
||||
if let artist = metadata["artist"] {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = artist
|
||||
print("[NowPlaying] Setting artist: \(artist)")
|
||||
}
|
||||
if let albumTitle = metadata["albumTitle"] {
|
||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = albumTitle
|
||||
print("[NowPlaying] Setting album: \(albumTitle)")
|
||||
}
|
||||
}
|
||||
|
||||
// Add artwork if available
|
||||
if let artwork = self.artworkImage {
|
||||
print("[NowPlaying] Setting artwork with size: \(artwork.size)")
|
||||
let artworkItem = MPMediaItemArtwork(boundsSize: artwork.size) { _ in
|
||||
return artwork
|
||||
}
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = artworkItem
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Expo Events
|
||||
|
||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
||||
@objc var onVideoError: RCTDirectEventBlock?
|
||||
@objc var onPipStarted: RCTDirectEventBlock?
|
||||
|
||||
// MARK: - Deinitialization
|
||||
|
||||
deinit {
|
||||
performStop()
|
||||
}
|
||||
}
|
||||
|
||||
extension VlcPlayerView: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||
// self?.updateVideoProgress()
|
||||
let timeNow = Date().timeIntervalSince1970
|
||||
if timeNow - lastProgressCall >= 1 {
|
||||
lastProgressCall = timeNow
|
||||
updateVideoProgress()
|
||||
}
|
||||
}
|
||||
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||
self.updatePlayerState()
|
||||
}
|
||||
|
||||
private func updatePlayerState() {
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
let currentState = player.state
|
||||
|
||||
var stateInfo: [String: Any] = [
|
||||
"target": self.reactTag ?? NSNull(),
|
||||
"currentTime": player.time.intValue,
|
||||
"duration": player.media?.length.intValue ?? 0,
|
||||
"error": false,
|
||||
]
|
||||
|
||||
if player.isPlaying {
|
||||
stateInfo["isPlaying"] = true
|
||||
stateInfo["isBuffering"] = false
|
||||
stateInfo["state"] = "Playing"
|
||||
} else {
|
||||
stateInfo["isPlaying"] = false
|
||||
stateInfo["state"] = "Paused"
|
||||
}
|
||||
|
||||
if player.state == VLCMediaPlayerState.buffering {
|
||||
stateInfo["isBuffering"] = true
|
||||
stateInfo["state"] = "Buffering"
|
||||
} else if player.state == VLCMediaPlayerState.error {
|
||||
print("player.state ~ error")
|
||||
stateInfo["state"] = "Error"
|
||||
self.onVideoLoadEnd?(stateInfo)
|
||||
} else if player.state == VLCMediaPlayerState.opening {
|
||||
print("player.state ~ opening")
|
||||
stateInfo["state"] = "Opening"
|
||||
}
|
||||
|
||||
if self.lastReportedState != currentState
|
||||
|| self.lastReportedIsPlaying != player.isPlaying
|
||||
{
|
||||
self.lastReportedState = currentState
|
||||
self.lastReportedIsPlaying = player.isPlaying
|
||||
self.onVideoStateChange?(stateInfo)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension VlcPlayerView: VLCMediaDelegate {
|
||||
// Implement VLCMediaDelegate methods if needed
|
||||
}
|
||||
|
||||
extension VLCMediaPlayerState {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .opening: return "Opening"
|
||||
case .buffering: return "Buffering"
|
||||
case .playing: return "Playing"
|
||||
case .paused: return "Paused"
|
||||
case .stopped: return "Stopped"
|
||||
case .ended: return "Ended"
|
||||
case .error: return "Error"
|
||||
case .esAdded: return "ESAdded"
|
||||
@unknown default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
|
||||
// It loads the native module object from the JSI or falls back to
|
||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
||||
export default requireNativeModule("VlcPlayer");
|
||||
8
modules/wifi-ssid/expo-module.config.json
Normal file
8
modules/wifi-ssid/expo-module.config.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "wifi-ssid",
|
||||
"version": "1.0.0",
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["WifiSsidModule"]
|
||||
}
|
||||
}
|
||||
44
modules/wifi-ssid/index.ts
Normal file
44
modules/wifi-ssid/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Platform, requireNativeModule } from "expo-modules-core";
|
||||
|
||||
// Only load the native module on iOS
|
||||
const WifiSsidModule =
|
||||
Platform.OS === "ios" ? requireNativeModule("WifiSsid") : null;
|
||||
|
||||
/**
|
||||
* Get the current WiFi SSID on iOS.
|
||||
* Returns null on Android or if not connected to WiFi.
|
||||
*
|
||||
* Requires:
|
||||
* - Location permission granted
|
||||
* - com.apple.developer.networking.wifi-info entitlement
|
||||
* - Access WiFi Information capability enabled in Apple Developer Portal
|
||||
*/
|
||||
export async function getSSID(): Promise<string | null> {
|
||||
if (!WifiSsidModule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const ssid = await WifiSsidModule.getSSID();
|
||||
return ssid ?? null;
|
||||
} catch (error) {
|
||||
console.error("[WifiSsid] Error getting SSID:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version - uses older CNCopyCurrentNetworkInfo API
|
||||
*/
|
||||
export function getSSIDSync(): string | null {
|
||||
if (!WifiSsidModule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return WifiSsidModule.getSSIDSync() ?? null;
|
||||
} catch (error) {
|
||||
console.error("[WifiSsid] Error getting SSID (sync):", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
22
modules/wifi-ssid/ios/WifiSsid.podspec
Normal file
22
modules/wifi-ssid/ios/WifiSsid.podspec
Normal file
@@ -0,0 +1,22 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'WifiSsid'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Get WiFi SSID on iOS'
|
||||
s.description = 'Native iOS module to get current WiFi SSID using NEHotspotNetwork'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = { :ios => '15.6', :tvos => '15.0' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.frameworks = 'NetworkExtension', 'SystemConfiguration'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
66
modules/wifi-ssid/ios/WifiSsidModule.swift
Normal file
66
modules/wifi-ssid/ios/WifiSsidModule.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import ExpoModulesCore
|
||||
#if !os(tvOS)
|
||||
import NetworkExtension
|
||||
import SystemConfiguration.CaptiveNetwork
|
||||
#endif
|
||||
|
||||
public class WifiSsidModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("WifiSsid")
|
||||
|
||||
// Get current WiFi SSID using NEHotspotNetwork (iOS 14+)
|
||||
// Not available on tvOS
|
||||
AsyncFunction("getSSID") { () -> String? in
|
||||
#if os(tvOS)
|
||||
return nil
|
||||
#else
|
||||
return await withCheckedContinuation { continuation in
|
||||
NEHotspotNetwork.fetchCurrent { network in
|
||||
if let ssid = network?.ssid {
|
||||
print("[WifiSsid] Got SSID via NEHotspotNetwork: \(ssid)")
|
||||
continuation.resume(returning: ssid)
|
||||
} else {
|
||||
// Fallback to CNCopyCurrentNetworkInfo for older iOS
|
||||
print("[WifiSsid] NEHotspotNetwork returned nil, trying CNCopyCurrentNetworkInfo")
|
||||
let ssid = self.getSSIDViaCNCopy()
|
||||
continuation.resume(returning: ssid)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Synchronous version using only CNCopyCurrentNetworkInfo
|
||||
// Not available on tvOS
|
||||
Function("getSSIDSync") { () -> String? in
|
||||
#if os(tvOS)
|
||||
return nil
|
||||
#else
|
||||
return self.getSSIDViaCNCopy()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private func getSSIDViaCNCopy() -> String? {
|
||||
guard let interfaces = CNCopySupportedInterfaces() as? [String] else {
|
||||
print("[WifiSsid] CNCopySupportedInterfaces returned nil")
|
||||
return nil
|
||||
}
|
||||
|
||||
for interface in interfaces {
|
||||
guard let networkInfo = CNCopyCurrentNetworkInfo(interface as CFString) as? [String: Any] else {
|
||||
continue
|
||||
}
|
||||
|
||||
if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String {
|
||||
print("[WifiSsid] Got SSID via CNCopyCurrentNetworkInfo: \(ssid)")
|
||||
return ssid
|
||||
}
|
||||
}
|
||||
|
||||
print("[WifiSsid] No SSID found via CNCopyCurrentNetworkInfo")
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user