mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
feat: KSPlayer as an option for iOS + other improvements (#1266)
This commit is contained in:
committed by
GitHub
parent
d1795c9df8
commit
74d86b5d12
@@ -112,14 +112,19 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const processedSource: VlcPlayerSource =
|
||||
const baseSource: VlcPlayerSource =
|
||||
typeof source === "string"
|
||||
? ({ uri: source } as unknown as VlcPlayerSource)
|
||||
: source;
|
||||
|
||||
if (processedSource.startPosition !== undefined) {
|
||||
processedSource.startPosition = Math.floor(processedSource.startPosition);
|
||||
}
|
||||
// Create a new object to avoid mutating frozen source
|
||||
const processedSource: VlcPlayerSource = {
|
||||
...baseSource,
|
||||
startPosition:
|
||||
baseSource.startPosition !== undefined
|
||||
? Math.floor(baseSource.startPosition)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
|
||||
@@ -35,7 +35,7 @@ android {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
import type {
|
||||
// Background Downloader
|
||||
export type {
|
||||
ActiveDownload,
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
} from "./background-downloader";
|
||||
export { default as BackgroundDownloader } from "./background-downloader";
|
||||
|
||||
// Streamyfin Player (KSPlayer-based) - GPU acceleration + native PiP (iOS)
|
||||
export type {
|
||||
AudioTrack as SfAudioTrack,
|
||||
OnErrorEventPayload as SfOnErrorEventPayload,
|
||||
OnLoadEventPayload as SfOnLoadEventPayload,
|
||||
OnPictureInPictureChangePayload as SfOnPictureInPictureChangePayload,
|
||||
OnPlaybackStateChangePayload as SfOnPlaybackStateChangePayload,
|
||||
OnProgressEventPayload as SfOnProgressEventPayload,
|
||||
OnTracksReadyEventPayload as SfOnTracksReadyEventPayload,
|
||||
SfPlayerViewProps,
|
||||
SfPlayerViewRef,
|
||||
SubtitleTrack as SfSubtitleTrack,
|
||||
VideoSource as SfVideoSource,
|
||||
} from "./sf-player";
|
||||
export {
|
||||
getHardwareDecode,
|
||||
SfPlayerView,
|
||||
setHardwareDecode,
|
||||
} from "./sf-player";
|
||||
|
||||
// VLC Player (Android)
|
||||
export type {
|
||||
ChapterInfo,
|
||||
NowPlayingMetadata,
|
||||
PipStartedPayload,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
TrackInfo,
|
||||
@@ -10,32 +43,4 @@ import type {
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
} from "./VlcPlayer.types";
|
||||
import VlcPlayerView from "./VlcPlayerView";
|
||||
|
||||
export type {
|
||||
ActiveDownload,
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
} 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)
|
||||
export type {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VideoLoadStartPayload,
|
||||
VideoProgressPayload,
|
||||
VideoStateChangePayload,
|
||||
};
|
||||
export { default as VlcPlayerView } from "./VlcPlayerView";
|
||||
|
||||
43
modules/mpv-player/android/build.gradle
Normal file
43
modules/mpv-player/android/build.gradle
Normal file
@@ -0,0 +1,43 @@
|
||||
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", 24)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.mpvplayer"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.7.6"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
2
modules/mpv-player/android/src/main/AndroidManifest.xml
Normal file
2
modules/mpv-player/android/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
@@ -0,0 +1,50 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import java.net.URL
|
||||
|
||||
class MpvPlayerModule : Module() {
|
||||
// Each module class must implement the definition function. The definition consists of components
|
||||
// that describes the module's functionality and behavior.
|
||||
// See https://docs.expo.dev/modules/module-api for more details about available components.
|
||||
override fun definition() = ModuleDefinition {
|
||||
// Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
|
||||
// Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
|
||||
// The module will be accessible from `requireNativeModule('MpvPlayer')` in JavaScript.
|
||||
Name("MpvPlayer")
|
||||
|
||||
// Defines constant property on the module.
|
||||
Constant("PI") {
|
||||
Math.PI
|
||||
}
|
||||
|
||||
// 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 world! 👋"
|
||||
}
|
||||
|
||||
// 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 ->
|
||||
// Send an event to JavaScript.
|
||||
sendEvent("onChange", mapOf(
|
||||
"value" to 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::class) {
|
||||
// Defines a setter for the `url` prop.
|
||||
Prop("url") { view: MpvPlayerView, url: URL ->
|
||||
view.webView.loadUrl(url.toString())
|
||||
}
|
||||
// Defines an event that the view can send to JavaScript.
|
||||
Events("onLoad")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||
// Creates and initializes an event dispatcher for the `onLoad` event.
|
||||
// The name of the event is inferred from the value and needs to match the event name defined in the module.
|
||||
private val onLoad by EventDispatcher()
|
||||
|
||||
// Defines a WebView that will be used as the root subview.
|
||||
internal val webView = WebView(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
// Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript.
|
||||
onLoad(mapOf("url" to url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Adds the WebView to the view hierarchy.
|
||||
addView(webView)
|
||||
}
|
||||
}
|
||||
6
modules/mpv-player/expo-module.config.json
Normal file
6
modules/mpv-player/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android", "web"],
|
||||
"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
|
||||
|
||||
class Logger {
|
||||
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
|
||||
}
|
||||
}
|
||||
1389
modules/mpv-player/ios/MPVSoftwareRenderer.swift
Normal file
1389
modules/mpv-player/ios/MPVSoftwareRenderer.swift
Normal file
File diff suppressed because it is too large
Load Diff
28
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
28
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
@@ -0,0 +1,28 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'MpvPlayer'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'MPVKit for Expo'
|
||||
s.description = 'MPVKit for Expo'
|
||||
s.author = 'mpvkit'
|
||||
s.homepage = 'https://github.com/mpvkit/MPVKit'
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'MPVKit', '~> 0.40.0'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
# Strip debug symbols to avoid DWARF errors from MPVKit
|
||||
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
||||
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
||||
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
171
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
171
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
@@ -0,0 +1,171 @@
|
||||
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 }
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
view.loadVideo(config: config)
|
||||
}
|
||||
|
||||
// Async function to play video
|
||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
// Async function to pause video
|
||||
AsyncFunction("pause") { (view: MpvPlayerView) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
}
|
||||
}
|
||||
}
|
||||
397
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
397
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
@@ -0,0 +1,397 @@
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import ExpoModulesCore
|
||||
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?
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
headers: [String: String]? = nil,
|
||||
externalSubtitles: [String]? = nil,
|
||||
startPosition: Double? = nil,
|
||||
autoplay: Bool = true,
|
||||
initialSubtitleId: Int? = nil,
|
||||
initialAudioId: Int? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
self.externalSubtitles = externalSubtitles
|
||||
self.startPosition = startPosition
|
||||
self.autoplay = autoplay
|
||||
self.initialSubtitleId = initialSubtitleId
|
||||
self.initialAudioId = initialAudioId
|
||||
}
|
||||
}
|
||||
|
||||
// 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: MPVSoftwareRenderer?
|
||||
private var videoContainer: UIView!
|
||||
private var pipController: PiPController?
|
||||
|
||||
let onLoad = EventDispatcher()
|
||||
let onPlaybackStateChange = EventDispatcher()
|
||||
let onProgress = EventDispatcher()
|
||||
let onError = EventDispatcher()
|
||||
let onTracksReady = EventDispatcher()
|
||||
|
||||
private var currentURL: URL?
|
||||
private var cachedPosition: Double = 0
|
||||
private var cachedDuration: Double = 0
|
||||
private var intendedPlayState: Bool = false // For PiP - ignores transient states during seek
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
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 #available(iOS 17.0, *) {
|
||||
displayLayer.wantsExtendedDynamicRangeContent = true
|
||||
}
|
||||
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 = MPVSoftwareRenderer(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)"])
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
displayLayer.frame = videoContainer.bounds
|
||||
displayLayer.isHidden = false
|
||||
displayLayer.opacity = 1.0
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
renderer?.play()
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
intendedPlayState = false
|
||||
renderer?.pausePlayback()
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func seekTo(position: Double) {
|
||||
renderer?.seek(to: position)
|
||||
}
|
||||
|
||||
func seekBy(offset: Double) {
|
||||
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)
|
||||
}
|
||||
|
||||
deinit {
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
displayLayer.removeFromSuperlayer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MPVSoftwareRendererDelegate
|
||||
|
||||
extension MpvPlayerView: MPVSoftwareRendererDelegate {
|
||||
func renderer(_: MPVSoftwareRenderer, didUpdatePosition position: Double, duration: Double) {
|
||||
cachedPosition = position
|
||||
cachedDuration = duration
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
// Only update PiP state when PiP is active
|
||||
if self.pipController?.isPictureInPictureActive == true {
|
||||
self.pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
self.onProgress([
|
||||
"position": position,
|
||||
"duration": duration,
|
||||
"progress": duration > 0 ? position / duration : 0,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVSoftwareRenderer, didChangePause isPaused: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
// Don't update intendedPlayState here - it's only set by user actions (play/pause)
|
||||
// This prevents PiP UI flicker during seeking
|
||||
self.onPlaybackStateChange([
|
||||
"isPaused": isPaused,
|
||||
"isPlaying": !isPaused,
|
||||
])
|
||||
// Note: Don't call updatePlaybackState() here to avoid flicker
|
||||
// PiP queries pipControllerIsPlaying when it needs the state
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVSoftwareRenderer, didChangeLoading isLoading: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
"isLoading": isLoading,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVSoftwareRenderer, didBecomeReadyToSeek: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
"isReadyToSeek": didBecomeReadyToSeek,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVSoftwareRenderer, didBecomeTracksReady: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onTracksReady([:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) {
|
||||
print("PiP did start: \(didStartPictureInPicture)")
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||
print("PiP restore user interface")
|
||||
completionHandler(true)
|
||||
}
|
||||
|
||||
func pipControllerPlay(_ controller: PiPController) {
|
||||
print("PiP play requested")
|
||||
play()
|
||||
}
|
||||
|
||||
func pipControllerPause(_ controller: PiPController) {
|
||||
print("PiP pause requested")
|
||||
pause()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
172
modules/mpv-player/ios/PiPController.swift
Normal file
172
modules/mpv-player/ios/PiPController.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
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
|
||||
}
|
||||
|
||||
final class PiPController: NSObject {
|
||||
private var pipController: AVPictureInPictureController?
|
||||
private weak var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer?
|
||||
|
||||
weak var delegate: PiPControllerDelegate?
|
||||
|
||||
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()
|
||||
setupPictureInPicture()
|
||||
}
|
||||
|
||||
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() {
|
||||
if Thread.isMainThread {
|
||||
pipController?.invalidatePlaybackState()
|
||||
} else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
72
modules/mpv-player/ios/SampleBufferDisplayView.swift
Normal file
72
modules/mpv-player/ios/SampleBufferDisplayView.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
final class SampleBufferDisplayView: UIView {
|
||||
override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self }
|
||||
|
||||
var displayLayer: AVSampleBufferDisplayLayer {
|
||||
return layer as! AVSampleBufferDisplayLayer
|
||||
}
|
||||
|
||||
private(set) var pipController: PiPController?
|
||||
|
||||
weak var pipDelegate: PiPControllerDelegate? {
|
||||
didSet {
|
||||
pipController?.delegate = pipDelegate
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
private func commonInit() {
|
||||
backgroundColor = .black
|
||||
displayLayer.videoGravity = .resizeAspect
|
||||
#if !os(tvOS)
|
||||
#if compiler(>=6.0)
|
||||
if #available(iOS 26.0, *) {
|
||||
displayLayer.preferredDynamicRange = .automatic
|
||||
} else if #available(iOS 17.0, *) {
|
||||
displayLayer.wantsExtendedDynamicRangeContent = true
|
||||
}
|
||||
#endif
|
||||
if #available(iOS 17.0, *) {
|
||||
displayLayer.wantsExtendedDynamicRangeContent = true
|
||||
}
|
||||
#endif
|
||||
setupPictureInPicture()
|
||||
}
|
||||
|
||||
private func setupPictureInPicture() {
|
||||
pipController = PiPController(sampleBufferDisplayLayer: displayLayer)
|
||||
}
|
||||
|
||||
// MARK: - PiP Control Methods
|
||||
|
||||
func startPictureInPicture() {
|
||||
pipController?.startPictureInPicture()
|
||||
}
|
||||
|
||||
func stopPictureInPicture() {
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
var isPictureInPictureSupported: Bool {
|
||||
return pipController?.isPictureInPictureSupported ?? false
|
||||
}
|
||||
|
||||
var isPictureInPictureActive: Bool {
|
||||
return pipController?.isPictureInPictureActive ?? false
|
||||
}
|
||||
|
||||
var isPictureInPicturePossible: Bool {
|
||||
return pipController?.isPictureInPicturePossible ?? false
|
||||
}
|
||||
}
|
||||
105
modules/mpv-player/src/MpvPlayer.types.ts
Normal file
105
modules/mpv-player/src/MpvPlayer.types.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export type OnErrorEventPayload = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export type MpvPlayerViewProps = {
|
||||
source?: VideoSource;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
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;
|
||||
};
|
||||
|
||||
export interface MpvPlayerViewRef {
|
||||
play: () => Promise<void>;
|
||||
pause: () => 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>;
|
||||
// Audio controls
|
||||
getAudioTracks: () => Promise<AudioTrack[]>;
|
||||
setAudioTrack: (trackId: number) => Promise<void>;
|
||||
getCurrentAudioTrack: () => Promise<number>;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
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");
|
||||
101
modules/mpv-player/src/MpvPlayerView.tsx
Normal file
101
modules/mpv-player/src/MpvPlayerView.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
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");
|
||||
|
||||
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();
|
||||
},
|
||||
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 () => {
|
||||
await nativeRef.current?.startPictureInPicture();
|
||||
},
|
||||
stopPictureInPicture: async () => {
|
||||
await nativeRef.current?.stopPictureInPicture();
|
||||
},
|
||||
isPictureInPictureSupported: async () => {
|
||||
return await nativeRef.current?.isPictureInPictureSupported();
|
||||
},
|
||||
isPictureInPictureActive: async () => {
|
||||
return await nativeRef.current?.isPictureInPictureActive();
|
||||
},
|
||||
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);
|
||||
},
|
||||
// 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();
|
||||
},
|
||||
}));
|
||||
|
||||
return <NativeView ref={nativeRef} {...props} />;
|
||||
},
|
||||
);
|
||||
15
modules/mpv-player/src/MpvPlayerView.web.tsx
Normal file
15
modules/mpv-player/src/MpvPlayerView.web.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { MpvPlayerViewProps } from "./MpvPlayer.types";
|
||||
|
||||
export default function MpvPlayerView(props: MpvPlayerViewProps) {
|
||||
const url = props.source?.url;
|
||||
return (
|
||||
<div>
|
||||
<iframe
|
||||
title='MPV Player'
|
||||
style={{ flex: 1 }}
|
||||
src={url}
|
||||
onLoad={() => props.onLoad?.({ nativeEvent: { url: 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";
|
||||
71
modules/sf-player/android/build.gradle
Normal file
71
modules/sf-player/android/build.gradle
Normal file
@@ -0,0 +1,71 @@
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'maven-publish'
|
||||
|
||||
group = 'expo.modules.sfplayer'
|
||||
version = '1.0.0'
|
||||
|
||||
buildscript {
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
if (expoModulesCorePlugin.exists()) {
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
publishing {
|
||||
publications {
|
||||
release(MavenPublication) {
|
||||
from components.release
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
url = mavenLocal().url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
||||
|
||||
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
|
||||
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.majorVersion
|
||||
}
|
||||
}
|
||||
|
||||
namespace "expo.modules.sfplayer"
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 23)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
publishing {
|
||||
singleVariant("release") {
|
||||
withSourcesJar()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':expo-modules-core')
|
||||
}
|
||||
|
||||
def safeExtGet(prop, fallback) {
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
2
modules/sf-player/android/src/main/AndroidManifest.xml
Normal file
2
modules/sf-player/android/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
@@ -0,0 +1,120 @@
|
||||
package expo.modules.sfplayer
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class SfPlayerModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("SfPlayer")
|
||||
|
||||
View(SfPlayerView::class) {
|
||||
Prop("source") { view: SfPlayerView, source: Map<String, Any>? ->
|
||||
// Android stub - KSPlayer is iOS only
|
||||
}
|
||||
|
||||
AsyncFunction("play") { view: SfPlayerView ->
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { view: SfPlayerView ->
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { view: SfPlayerView, position: Double ->
|
||||
}
|
||||
|
||||
AsyncFunction("seekBy") { view: SfPlayerView, offset: Double ->
|
||||
}
|
||||
|
||||
AsyncFunction("setSpeed") { view: SfPlayerView, speed: Double ->
|
||||
}
|
||||
|
||||
AsyncFunction("getSpeed") { view: SfPlayerView ->
|
||||
1.0
|
||||
}
|
||||
|
||||
AsyncFunction("isPaused") { view: SfPlayerView ->
|
||||
true
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentPosition") { view: SfPlayerView ->
|
||||
0.0
|
||||
}
|
||||
|
||||
AsyncFunction("getDuration") { view: SfPlayerView ->
|
||||
0.0
|
||||
}
|
||||
|
||||
AsyncFunction("startPictureInPicture") { view: SfPlayerView ->
|
||||
}
|
||||
|
||||
AsyncFunction("stopPictureInPicture") { view: SfPlayerView ->
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureSupported") { view: SfPlayerView ->
|
||||
false
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureActive") { view: SfPlayerView ->
|
||||
false
|
||||
}
|
||||
|
||||
AsyncFunction("getSubtitleTracks") { view: SfPlayerView ->
|
||||
emptyList<Map<String, Any>>()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { view: SfPlayerView, trackId: Int ->
|
||||
}
|
||||
|
||||
AsyncFunction("disableSubtitles") { view: SfPlayerView ->
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentSubtitleTrack") { view: SfPlayerView ->
|
||||
0
|
||||
}
|
||||
|
||||
AsyncFunction("addSubtitleFile") { view: SfPlayerView, url: String, select: Boolean ->
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitlePosition") { view: SfPlayerView, position: Int ->
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleScale") { view: SfPlayerView, scale: Double ->
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleMarginY") { view: SfPlayerView, margin: Int ->
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignX") { view: SfPlayerView, alignment: String ->
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignY") { view: SfPlayerView, alignment: String ->
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleFontSize") { view: SfPlayerView, size: Int ->
|
||||
}
|
||||
|
||||
AsyncFunction("getAudioTracks") { view: SfPlayerView ->
|
||||
emptyList<Map<String, Any>>()
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { view: SfPlayerView, trackId: Int ->
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentAudioTrack") { view: SfPlayerView ->
|
||||
0
|
||||
}
|
||||
|
||||
AsyncFunction("setVideoZoomToFill") { view: SfPlayerView, enabled: Boolean ->
|
||||
}
|
||||
|
||||
AsyncFunction("getVideoZoomToFill") { view: SfPlayerView ->
|
||||
false
|
||||
}
|
||||
|
||||
AsyncFunction("setAutoPipEnabled") { view: SfPlayerView, enabled: Boolean ->
|
||||
}
|
||||
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package expo.modules.sfplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
class SfPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||
|
||||
private val placeholder: View = View(context).apply {
|
||||
setBackgroundColor(android.graphics.Color.BLACK)
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
addView(placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
9
modules/sf-player/expo-module.config.json
Normal file
9
modules/sf-player/expo-module.config.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos", "android"],
|
||||
"ios": {
|
||||
"modules": ["SfPlayerModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.sfplayer.SfPlayerModule"]
|
||||
}
|
||||
}
|
||||
1
modules/sf-player/index.ts
Normal file
1
modules/sf-player/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
32
modules/sf-player/ios/SfPlayer.podspec
Normal file
32
modules/sf-player/ios/SfPlayer.podspec
Normal file
@@ -0,0 +1,32 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'SfPlayer'
|
||||
s.module_name = 'SfPlayer'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Streamyfin Player - KSPlayer wrapper for Expo'
|
||||
s.description = 'Video player with GPU acceleration and PiP support for Expo, powered by KSPlayer'
|
||||
s.author = 'streamyfin'
|
||||
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||
s.license = { :type => 'MPL-2.0' }
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: 'https://github.com/streamyfin/streamyfin.git' }
|
||||
s.static_framework = true
|
||||
s.swift_version = '5.9'
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'KSPlayer'
|
||||
s.dependency 'DisplayCriteria'
|
||||
|
||||
# KSPlayer pods are injected into the Podfile via plugins/withKSPlayer.js
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
||||
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
||||
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
179
modules/sf-player/ios/SfPlayerModule.swift
Normal file
179
modules/sf-player/ios/SfPlayerModule.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class SfPlayerModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("SfPlayer")
|
||||
|
||||
// Module-level functions (not tied to a specific view instance)
|
||||
Function("setHardwareDecode") { (enabled: Bool) in
|
||||
SfPlayerView.setHardwareDecode(enabled)
|
||||
}
|
||||
|
||||
Function("getHardwareDecode") { () -> Bool in
|
||||
return SfPlayerView.getHardwareDecode()
|
||||
}
|
||||
|
||||
// Enables the module to be used as a native view
|
||||
View(SfPlayerView.self) {
|
||||
// All video load options are passed via a single "source" prop
|
||||
Prop("source") { (view: SfPlayerView, source: [String: Any]?) in
|
||||
guard let source = source,
|
||||
let urlString = source["url"] as? String,
|
||||
let videoURL = URL(string: urlString) else { return }
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
view.loadVideo(config: config)
|
||||
}
|
||||
|
||||
// Playback controls
|
||||
AsyncFunction("play") { (view: SfPlayerView) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { (view: SfPlayerView) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { (view: SfPlayerView, position: Double) in
|
||||
view.seekTo(position: position)
|
||||
}
|
||||
|
||||
AsyncFunction("seekBy") { (view: SfPlayerView, offset: Double) in
|
||||
view.seekBy(offset: offset)
|
||||
}
|
||||
|
||||
AsyncFunction("setSpeed") { (view: SfPlayerView, speed: Double) in
|
||||
view.setSpeed(speed: speed)
|
||||
}
|
||||
|
||||
AsyncFunction("getSpeed") { (view: SfPlayerView) -> Double in
|
||||
return view.getSpeed()
|
||||
}
|
||||
|
||||
AsyncFunction("isPaused") { (view: SfPlayerView) -> Bool in
|
||||
return view.isPaused()
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentPosition") { (view: SfPlayerView) -> Double in
|
||||
return view.getCurrentPosition()
|
||||
}
|
||||
|
||||
AsyncFunction("getDuration") { (view: SfPlayerView) -> Double in
|
||||
return view.getDuration()
|
||||
}
|
||||
|
||||
// Picture in Picture
|
||||
AsyncFunction("startPictureInPicture") { (view: SfPlayerView) in
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("stopPictureInPicture") { (view: SfPlayerView) in
|
||||
view.stopPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureSupported") { (view: SfPlayerView) -> Bool in
|
||||
return view.isPictureInPictureSupported()
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureActive") { (view: SfPlayerView) -> Bool in
|
||||
return view.isPictureInPictureActive()
|
||||
}
|
||||
|
||||
AsyncFunction("setAutoPipEnabled") { (view: SfPlayerView, enabled: Bool) in
|
||||
view.setAutoPipEnabled(enabled)
|
||||
}
|
||||
|
||||
// Subtitle functions
|
||||
AsyncFunction("getSubtitleTracks") { (view: SfPlayerView) -> [[String: Any]] in
|
||||
return view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { (view: SfPlayerView, trackId: Int) in
|
||||
view.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("disableSubtitles") { (view: SfPlayerView) in
|
||||
view.disableSubtitles()
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentSubtitleTrack") { (view: SfPlayerView) -> Int in
|
||||
return view.getCurrentSubtitleTrack()
|
||||
}
|
||||
|
||||
AsyncFunction("addSubtitleFile") { (view: SfPlayerView, url: String, select: Bool) in
|
||||
view.addSubtitleFile(url: url, select: select)
|
||||
}
|
||||
|
||||
// Subtitle positioning
|
||||
AsyncFunction("setSubtitlePosition") { (view: SfPlayerView, position: Int) in
|
||||
view.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleScale") { (view: SfPlayerView, scale: Double) in
|
||||
view.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleMarginY") { (view: SfPlayerView, margin: Int) in
|
||||
view.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignX") { (view: SfPlayerView, alignment: String) in
|
||||
view.setSubtitleAlignX(alignment)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignY") { (view: SfPlayerView, alignment: String) in
|
||||
view.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleFontSize") { (view: SfPlayerView, size: Int) in
|
||||
view.setSubtitleFontSize(size)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleColor") { (view: SfPlayerView, hexColor: String) in
|
||||
view.setSubtitleColor(hexColor)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleBackgroundColor") { (view: SfPlayerView, hexColor: String) in
|
||||
view.setSubtitleBackgroundColor(hexColor)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleFontName") { (view: SfPlayerView, fontName: String) in
|
||||
view.setSubtitleFontName(fontName)
|
||||
}
|
||||
|
||||
// Audio track functions
|
||||
AsyncFunction("getAudioTracks") { (view: SfPlayerView) -> [[String: Any]] in
|
||||
return view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { (view: SfPlayerView, trackId: Int) in
|
||||
view.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentAudioTrack") { (view: SfPlayerView) -> Int in
|
||||
return view.getCurrentAudioTrack()
|
||||
}
|
||||
|
||||
// Video zoom
|
||||
AsyncFunction("setVideoZoomToFill") { (view: SfPlayerView, enabled: Bool) in
|
||||
view.setVideoZoomToFill(enabled)
|
||||
}
|
||||
|
||||
AsyncFunction("getVideoZoomToFill") { (view: SfPlayerView) -> Bool in
|
||||
return view.getVideoZoomToFill()
|
||||
}
|
||||
|
||||
// Events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
317
modules/sf-player/ios/SfPlayerView.swift
Normal file
317
modules/sf-player/ios/SfPlayerView.swift
Normal file
@@ -0,0 +1,317 @@
|
||||
import AVFoundation
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
|
||||
class SfPlayerView: ExpoView {
|
||||
private var player: SfPlayerWrapper?
|
||||
private var videoContainer: UIView!
|
||||
|
||||
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
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupView()
|
||||
}
|
||||
|
||||
private func setupView() {
|
||||
clipsToBounds = true
|
||||
backgroundColor = .black
|
||||
|
||||
videoContainer = UIView()
|
||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
videoContainer.backgroundColor = .black
|
||||
videoContainer.clipsToBounds = true
|
||||
addSubview(videoContainer)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
videoContainer.topAnchor.constraint(equalTo: topAnchor),
|
||||
videoContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
videoContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
videoContainer.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
// Initialize player
|
||||
player = SfPlayerWrapper()
|
||||
player?.delegate = self
|
||||
|
||||
// Configure Audio Session for PiP and background playback
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
|
||||
// Add player view to container
|
||||
if let playerView = player?.view {
|
||||
playerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
videoContainer.addSubview(playerView)
|
||||
NSLayoutConstraint.activate([
|
||||
playerView.topAnchor.constraint(equalTo: videoContainer.topAnchor),
|
||||
playerView.leadingAnchor.constraint(equalTo: videoContainer.leadingAnchor),
|
||||
playerView.trailingAnchor.constraint(equalTo: videoContainer.trailingAnchor),
|
||||
playerView.bottomAnchor.constraint(equalTo: videoContainer.bottomAnchor)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
player?.updateLayout(bounds: videoContainer.bounds)
|
||||
}
|
||||
|
||||
// MARK: - Video Loading
|
||||
|
||||
func loadVideo(config: VideoLoadConfig) {
|
||||
// Skip reload if same URL is already playing
|
||||
if currentURL == config.url {
|
||||
return
|
||||
}
|
||||
currentURL = config.url
|
||||
|
||||
player?.load(config: config)
|
||||
|
||||
if config.autoplay {
|
||||
play()
|
||||
}
|
||||
|
||||
onLoad(["url": config.url.absoluteString])
|
||||
}
|
||||
|
||||
func loadVideo(url: URL, headers: [String: String]? = nil) {
|
||||
loadVideo(config: VideoLoadConfig(url: url, headers: headers))
|
||||
}
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
func play() {
|
||||
intendedPlayState = true
|
||||
player?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
intendedPlayState = false
|
||||
player?.pause()
|
||||
}
|
||||
|
||||
func seekTo(position: Double) {
|
||||
player?.seek(to: position)
|
||||
}
|
||||
|
||||
func seekBy(offset: Double) {
|
||||
player?.seek(by: offset)
|
||||
}
|
||||
|
||||
func setSpeed(speed: Double) {
|
||||
player?.setSpeed(speed)
|
||||
}
|
||||
|
||||
func getSpeed() -> Double {
|
||||
return player?.getSpeed() ?? 1.0
|
||||
}
|
||||
|
||||
func isPaused() -> Bool {
|
||||
return player?.getIsPaused() ?? true
|
||||
}
|
||||
|
||||
func getCurrentPosition() -> Double {
|
||||
return cachedPosition
|
||||
}
|
||||
|
||||
func getDuration() -> Double {
|
||||
return cachedDuration
|
||||
}
|
||||
|
||||
// MARK: - Picture in Picture
|
||||
|
||||
func startPictureInPicture() {
|
||||
player?.startPictureInPicture()
|
||||
}
|
||||
|
||||
func stopPictureInPicture() {
|
||||
player?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
func isPictureInPictureSupported() -> Bool {
|
||||
return player?.isPictureInPictureSupported() ?? false
|
||||
}
|
||||
|
||||
func isPictureInPictureActive() -> Bool {
|
||||
return player?.isPictureInPictureActive() ?? false
|
||||
}
|
||||
|
||||
func setAutoPipEnabled(_ enabled: Bool) {
|
||||
player?.setAutoPipEnabled(enabled)
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
|
||||
func getSubtitleTracks() -> [[String: Any]] {
|
||||
return player?.getSubtitleTracks() ?? []
|
||||
}
|
||||
|
||||
func setSubtitleTrack(_ trackId: Int) {
|
||||
player?.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
func disableSubtitles() {
|
||||
player?.disableSubtitles()
|
||||
}
|
||||
|
||||
func getCurrentSubtitleTrack() -> Int {
|
||||
return player?.getCurrentSubtitleTrack() ?? 0
|
||||
}
|
||||
|
||||
func addSubtitleFile(url: String, select: Bool = true) {
|
||||
player?.addSubtitleFile(url: url, select: select)
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
func setSubtitlePosition(_ position: Int) {
|
||||
player?.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
func setSubtitleScale(_ scale: Double) {
|
||||
player?.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
func setSubtitleMarginY(_ margin: Int) {
|
||||
player?.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
func setSubtitleAlignX(_ alignment: String) {
|
||||
player?.setSubtitleAlignX(alignment)
|
||||
}
|
||||
|
||||
func setSubtitleAlignY(_ alignment: String) {
|
||||
player?.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
func setSubtitleFontSize(_ size: Int) {
|
||||
player?.setSubtitleFontSize(size)
|
||||
}
|
||||
|
||||
func setSubtitleColor(_ hexColor: String) {
|
||||
player?.setSubtitleColor(hexColor)
|
||||
}
|
||||
|
||||
func setSubtitleBackgroundColor(_ hexColor: String) {
|
||||
player?.setSubtitleBackgroundColor(hexColor)
|
||||
}
|
||||
|
||||
func setSubtitleFontName(_ fontName: String) {
|
||||
player?.setSubtitleFontName(fontName)
|
||||
}
|
||||
|
||||
// MARK: - Hardware Decode (static, affects all players)
|
||||
|
||||
static func setHardwareDecode(_ enabled: Bool) {
|
||||
SfPlayerWrapper.setHardwareDecode(enabled)
|
||||
}
|
||||
|
||||
static func getHardwareDecode() -> Bool {
|
||||
return SfPlayerWrapper.getHardwareDecode()
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
|
||||
func getAudioTracks() -> [[String: Any]] {
|
||||
return player?.getAudioTracks() ?? []
|
||||
}
|
||||
|
||||
func setAudioTrack(_ trackId: Int) {
|
||||
player?.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
func getCurrentAudioTrack() -> Int {
|
||||
return player?.getCurrentAudioTrack() ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Video Zoom
|
||||
|
||||
func setVideoZoomToFill(_ enabled: Bool) {
|
||||
player?.setVideoZoomToFill(enabled)
|
||||
}
|
||||
|
||||
func getVideoZoomToFill() -> Bool {
|
||||
return player?.getVideoZoomToFill() ?? false
|
||||
}
|
||||
|
||||
deinit {
|
||||
player?.stopPictureInPicture()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SfPlayerWrapperDelegate
|
||||
|
||||
extension SfPlayerView: SfPlayerWrapperDelegate {
|
||||
func player(_ player: SfPlayerWrapper, didUpdatePosition position: Double, duration: Double) {
|
||||
cachedPosition = position
|
||||
cachedDuration = duration
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onProgress([
|
||||
"position": position,
|
||||
"duration": duration,
|
||||
"progress": duration > 0 ? position / duration : 0,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func player(_ player: SfPlayerWrapper, didChangePause isPaused: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
"isPaused": isPaused,
|
||||
"isPlaying": !isPaused,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func player(_ player: SfPlayerWrapper, didChangeLoading isLoading: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
"isLoading": isLoading,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func player(_ player: SfPlayerWrapper, didBecomeReadyToSeek: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
"isReadyToSeek": didBecomeReadyToSeek,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func player(_ player: SfPlayerWrapper, didBecomeTracksReady: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onTracksReady([:])
|
||||
}
|
||||
}
|
||||
|
||||
func player(_ player: SfPlayerWrapper, didEncounterError error: String) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onError(["error": error])
|
||||
}
|
||||
}
|
||||
|
||||
func player(_ player: SfPlayerWrapper, didChangePictureInPicture isActive: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPictureInPictureChange(["isActive": isActive])
|
||||
}
|
||||
}
|
||||
}
|
||||
869
modules/sf-player/ios/SfPlayerWrapper.swift
Normal file
869
modules/sf-player/ios/SfPlayerWrapper.swift
Normal file
@@ -0,0 +1,869 @@
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import KSPlayer
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
protocol SfPlayerWrapperDelegate: AnyObject {
|
||||
func player(_ player: SfPlayerWrapper, didUpdatePosition position: Double, duration: Double)
|
||||
func player(_ player: SfPlayerWrapper, didChangePause isPaused: Bool)
|
||||
func player(_ player: SfPlayerWrapper, didChangeLoading isLoading: Bool)
|
||||
func player(_ player: SfPlayerWrapper, didBecomeReadyToSeek: Bool)
|
||||
func player(_ player: SfPlayerWrapper, didBecomeTracksReady: Bool)
|
||||
func player(_ player: SfPlayerWrapper, didEncounterError error: String)
|
||||
func player(_ player: SfPlayerWrapper, didChangePictureInPicture isActive: Bool)
|
||||
}
|
||||
|
||||
/// Configuration for loading a video
|
||||
struct VideoLoadConfig {
|
||||
let url: URL
|
||||
var headers: [String: String]?
|
||||
var externalSubtitles: [String]?
|
||||
var startPosition: Double?
|
||||
var autoplay: Bool
|
||||
var initialSubtitleId: Int?
|
||||
var initialAudioId: Int?
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
headers: [String: String]? = nil,
|
||||
externalSubtitles: [String]? = nil,
|
||||
startPosition: Double? = nil,
|
||||
autoplay: Bool = true,
|
||||
initialSubtitleId: Int? = nil,
|
||||
initialAudioId: Int? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
self.externalSubtitles = externalSubtitles
|
||||
self.startPosition = startPosition
|
||||
self.autoplay = autoplay
|
||||
self.initialSubtitleId = initialSubtitleId
|
||||
self.initialAudioId = initialAudioId
|
||||
}
|
||||
}
|
||||
|
||||
final class SfPlayerWrapper: NSObject {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var playerView: IOSVideoPlayerView?
|
||||
private var containerView: UIView?
|
||||
|
||||
private var cachedPosition: Double = 0
|
||||
private var cachedDuration: Double = 0
|
||||
private var isPaused: Bool = true
|
||||
private var isLoading: Bool = false
|
||||
private var currentURL: URL?
|
||||
private var pendingExternalSubtitles: [String] = []
|
||||
private var initialSubtitleId: Int?
|
||||
private var initialAudioId: Int?
|
||||
private var pendingStartPosition: Double?
|
||||
|
||||
private var progressTimer: Timer?
|
||||
private var pipController: AVPictureInPictureController?
|
||||
|
||||
/// Scale factor for image-based subtitles (PGS, VOBSUB)
|
||||
/// Default 1.0 = no scaling; setSubtitleFontSize derives scale from font size
|
||||
private var subtitleScale: CGFloat = 1.0
|
||||
/// When true, setSubtitleFontSize won't override the scale (user set explicit value)
|
||||
private var isScaleExplicitlySet: Bool = false
|
||||
/// Optional override for subtitle font family
|
||||
private var subtitleFontName: String?
|
||||
|
||||
weak var delegate: SfPlayerWrapperDelegate?
|
||||
|
||||
var view: UIView? { containerView }
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
setupPlayer()
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopProgressTimer()
|
||||
playerView?.pause()
|
||||
playerView = nil
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupPlayer() {
|
||||
// Configure KSPlayer options for hardware acceleration
|
||||
KSOptions.canBackgroundPlay = true
|
||||
KSOptions.isAutoPlay = false
|
||||
KSOptions.isSecondOpen = true
|
||||
KSOptions.isAccurateSeek = true
|
||||
KSOptions.hardwareDecode = true
|
||||
|
||||
// Create container view
|
||||
let container = UIView()
|
||||
container.backgroundColor = .black
|
||||
container.clipsToBounds = true
|
||||
containerView = container
|
||||
}
|
||||
|
||||
private func createPlayerView(frame: CGRect) -> IOSVideoPlayerView {
|
||||
let player = IOSVideoPlayerView()
|
||||
player.frame = frame
|
||||
player.delegate = self
|
||||
|
||||
// Hide ALL KSPlayer UI elements - we use our own JS controls
|
||||
player.toolBar.isHidden = true
|
||||
player.navigationBar.isHidden = true
|
||||
player.topMaskView.isHidden = true
|
||||
player.bottomMaskView.isHidden = true
|
||||
player.loadingIndector.isHidden = false
|
||||
player.seekToView.isHidden = true
|
||||
player.replayButton.isHidden = true
|
||||
player.lockButton.isHidden = true
|
||||
player.controllerView.isHidden = true
|
||||
player.titleLabel.isHidden = true
|
||||
|
||||
// Ensure subtitle views are visible for rendering
|
||||
player.subtitleBackView.isHidden = false
|
||||
player.subtitleLabel.isHidden = false
|
||||
|
||||
// Disable all gestures - handled in JS
|
||||
player.tapGesture.isEnabled = false
|
||||
player.doubleTapGesture.isEnabled = false
|
||||
player.panGesture.isEnabled = false
|
||||
|
||||
// Disable interaction on hidden elements
|
||||
player.controllerView.isUserInteractionEnabled = false
|
||||
applySubtitleFont()
|
||||
return player
|
||||
}
|
||||
|
||||
// MARK: - Progress Timer
|
||||
|
||||
private func startProgressTimer() {
|
||||
stopProgressTimer()
|
||||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
self?.updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopProgressTimer() {
|
||||
progressTimer?.invalidate()
|
||||
progressTimer = nil
|
||||
}
|
||||
|
||||
private func updateProgress() {
|
||||
guard let player = playerView?.playerLayer?.player else { return }
|
||||
|
||||
let position = player.currentPlaybackTime
|
||||
let duration = player.duration
|
||||
|
||||
if position != cachedPosition || duration != cachedDuration {
|
||||
cachedPosition = position
|
||||
cachedDuration = duration
|
||||
delegate?.player(self, didUpdatePosition: position, duration: duration)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
func load(config: VideoLoadConfig) {
|
||||
guard config.url != currentURL else { return }
|
||||
|
||||
currentURL = config.url
|
||||
pendingExternalSubtitles = config.externalSubtitles ?? []
|
||||
initialSubtitleId = config.initialSubtitleId
|
||||
initialAudioId = config.initialAudioId
|
||||
|
||||
// Store start position to seek after video is ready
|
||||
if let startPos = config.startPosition, startPos > 0 {
|
||||
pendingStartPosition = startPos
|
||||
} else {
|
||||
pendingStartPosition = nil
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
delegate?.player(self, didChangeLoading: true)
|
||||
|
||||
// Create or reset player view
|
||||
if playerView == nil, let container = containerView {
|
||||
let player = createPlayerView(frame: container.bounds)
|
||||
player.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(player)
|
||||
|
||||
// Pin player to all edges of container
|
||||
NSLayoutConstraint.activate([
|
||||
player.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
player.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
player.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
player.bottomAnchor.constraint(equalTo: container.bottomAnchor)
|
||||
])
|
||||
|
||||
playerView = player
|
||||
}
|
||||
|
||||
// Configure options for this media
|
||||
let options = KSOptions()
|
||||
|
||||
// Set HTTP headers if provided
|
||||
if let headers = config.headers, !headers.isEmpty {
|
||||
for (key, value) in headers {
|
||||
options.appendHeader(["key": key, "value": value])
|
||||
}
|
||||
}
|
||||
|
||||
// Note: startPosition is handled via explicit seek in readyToPlay callback
|
||||
// because KSPlayer's options.startPlayTime doesn't work reliably
|
||||
|
||||
// Set the URL with options
|
||||
playerView?.set(url: config.url, options: options)
|
||||
|
||||
if config.autoplay {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
isPaused = false
|
||||
playerView?.play()
|
||||
startProgressTimer()
|
||||
delegate?.player(self, didChangePause: false)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
isPaused = true
|
||||
playerView?.pause()
|
||||
delegate?.player(self, didChangePause: true)
|
||||
}
|
||||
|
||||
func seek(to seconds: Double) {
|
||||
let time = max(0, seconds)
|
||||
let wasPaused = isPaused
|
||||
cachedPosition = time
|
||||
playerView?.seek(time: time) { [weak self] finished in
|
||||
guard let self, finished else { return }
|
||||
// KSPlayer may auto-resume after seeking, so enforce the intended state
|
||||
if wasPaused {
|
||||
self.pause()
|
||||
}
|
||||
self.updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
func seek(by seconds: Double) {
|
||||
let newPosition = max(0, cachedPosition + seconds)
|
||||
seek(to: newPosition)
|
||||
}
|
||||
|
||||
func setSpeed(_ speed: Double) {
|
||||
playerView?.playerLayer?.player.playbackRate = Float(speed)
|
||||
}
|
||||
|
||||
func getSpeed() -> Double {
|
||||
return Double(playerView?.playerLayer?.player.playbackRate ?? 1.0)
|
||||
}
|
||||
|
||||
func getCurrentPosition() -> Double {
|
||||
return cachedPosition
|
||||
}
|
||||
|
||||
func getDuration() -> Double {
|
||||
return cachedDuration
|
||||
}
|
||||
|
||||
func getIsPaused() -> Bool {
|
||||
return isPaused
|
||||
}
|
||||
|
||||
// MARK: - Picture in Picture
|
||||
|
||||
private func setupPictureInPicture() {
|
||||
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
|
||||
|
||||
// Get the PiP controller from KSPlayer
|
||||
guard let pip = playerView?.playerLayer?.player.pipController else { return }
|
||||
|
||||
pipController = pip
|
||||
pip.delegate = self
|
||||
|
||||
// Enable automatic PiP when app goes to background (swipe up to home)
|
||||
if #available(iOS 14.2, *) {
|
||||
pip.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
}
|
||||
|
||||
func startPictureInPicture() {
|
||||
pipController?.startPictureInPicture()
|
||||
}
|
||||
|
||||
func stopPictureInPicture() {
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
func isPictureInPictureSupported() -> Bool {
|
||||
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||
}
|
||||
|
||||
func isPictureInPictureActive() -> Bool {
|
||||
return pipController?.isPictureInPictureActive ?? false
|
||||
}
|
||||
|
||||
func setAutoPipEnabled(_ enabled: Bool) {
|
||||
if #available(iOS 14.2, *) {
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
|
||||
func getSubtitleTracks() -> [[String: Any]] {
|
||||
var tracks: [[String: Any]] = []
|
||||
|
||||
// srtControl.subtitleInfos should contain ALL subtitles KSPlayer knows about
|
||||
// (both embedded that were auto-detected and external that were added)
|
||||
if let srtControl = playerView?.srtControl {
|
||||
let allSubtitles = srtControl.subtitleInfos
|
||||
let selectedInfo = srtControl.selectedSubtitleInfo
|
||||
|
||||
print("[SfPlayer] getSubtitleTracks - srtControl has \(allSubtitles.count) subtitles")
|
||||
|
||||
for (index, info) in allSubtitles.enumerated() {
|
||||
let isSelected = selectedInfo?.subtitleID == info.subtitleID
|
||||
let trackInfo: [String: Any] = [
|
||||
"id": index + 1, // 1-based ID
|
||||
"selected": isSelected,
|
||||
"title": info.name,
|
||||
"lang": "",
|
||||
"source": "srtControl"
|
||||
]
|
||||
tracks.append(trackInfo)
|
||||
print("[SfPlayer] [\(index + 1)]: \(info.name) (selected: \(isSelected))")
|
||||
}
|
||||
}
|
||||
|
||||
// Also log embedded tracks from player for debugging
|
||||
if let player = playerView?.playerLayer?.player {
|
||||
let embeddedTracks = player.tracks(mediaType: .subtitle)
|
||||
print("[SfPlayer] getSubtitleTracks - player.tracks has \(embeddedTracks.count) embedded tracks")
|
||||
for (i, track) in embeddedTracks.enumerated() {
|
||||
print("[SfPlayer] embedded[\(i)]: \(track.name) (enabled: \(track.isEnabled))")
|
||||
}
|
||||
}
|
||||
|
||||
return tracks
|
||||
}
|
||||
|
||||
func setSubtitleTrack(_ trackId: Int) {
|
||||
print("[SfPlayer] setSubtitleTrack called with trackId: \(trackId)")
|
||||
|
||||
// Handle disable case
|
||||
if trackId < 0 {
|
||||
print("[SfPlayer] Disabling subtitles (trackId < 0)")
|
||||
disableSubtitles()
|
||||
return
|
||||
}
|
||||
|
||||
guard let player = playerView?.playerLayer?.player,
|
||||
let srtControl = playerView?.srtControl else {
|
||||
print("[SfPlayer] setSubtitleTrack - player or srtControl not available")
|
||||
return
|
||||
}
|
||||
|
||||
let embeddedTracks = player.tracks(mediaType: .subtitle)
|
||||
let index = trackId - 1 // Convert to 0-based
|
||||
|
||||
print("[SfPlayer] setSubtitleTrack - embedded tracks: \(embeddedTracks.count), srtControl.subtitleInfos: \(srtControl.subtitleInfos.count), index: \(index)")
|
||||
|
||||
// Log all available subtitles for debugging
|
||||
print("[SfPlayer] Available in srtControl:")
|
||||
for (i, info) in srtControl.subtitleInfos.enumerated() {
|
||||
print("[SfPlayer] [\(i)]: \(info.name)")
|
||||
}
|
||||
|
||||
// KSPlayer's srtControl might contain all subtitles (embedded + external)
|
||||
// Try to find and select the subtitle at the given index in srtControl
|
||||
let allSubtitles = srtControl.subtitleInfos
|
||||
if index >= 0 && index < allSubtitles.count {
|
||||
let subtitleInfo = allSubtitles[index]
|
||||
srtControl.selectedSubtitleInfo = subtitleInfo
|
||||
playerView?.updateSrt()
|
||||
print("[SfPlayer] Selected subtitle from srtControl: \(subtitleInfo.name)")
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: try selecting embedded track directly via player.select()
|
||||
// This handles cases where srtControl doesn't have all embedded tracks
|
||||
if index >= 0 && index < embeddedTracks.count {
|
||||
let track = embeddedTracks[index]
|
||||
player.select(track: track)
|
||||
print("[SfPlayer] Fallback: Selected embedded track via player.select(): \(track.name)")
|
||||
return
|
||||
}
|
||||
|
||||
print("[SfPlayer] WARNING: index \(index) out of range")
|
||||
}
|
||||
|
||||
func disableSubtitles() {
|
||||
print("[SfPlayer] disableSubtitles called")
|
||||
|
||||
// Clear srtControl selection (handles both embedded and external via srtControl)
|
||||
playerView?.srtControl.selectedSubtitleInfo = nil
|
||||
playerView?.updateSrt()
|
||||
|
||||
// Also disable any embedded tracks selected via player.select()
|
||||
if let player = playerView?.playerLayer?.player {
|
||||
let subtitleTracks = player.tracks(mediaType: .subtitle)
|
||||
for track in subtitleTracks {
|
||||
if track.isEnabled {
|
||||
// KSPlayer doesn't have a direct "disable" - selecting a different track would disable this one
|
||||
print("[SfPlayer] Note: embedded track '\(track.name)' is still enabled at decoder level")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getCurrentSubtitleTrack() -> Int {
|
||||
guard let srtControl = playerView?.srtControl,
|
||||
let selectedInfo = srtControl.selectedSubtitleInfo else {
|
||||
return 0 // No subtitle selected
|
||||
}
|
||||
|
||||
// Find the selected subtitle in srtControl.subtitleInfos
|
||||
let allSubtitles = srtControl.subtitleInfos
|
||||
for (index, info) in allSubtitles.enumerated() {
|
||||
if info.subtitleID == selectedInfo.subtitleID {
|
||||
return index + 1 // 1-based ID
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func addSubtitleFile(url: String, select: Bool) {
|
||||
print("[SfPlayer] addSubtitleFile called with url: \(url), select: \(select)")
|
||||
guard let subUrl = URL(string: url) else {
|
||||
print("[SfPlayer] Failed to create URL from string")
|
||||
return
|
||||
}
|
||||
|
||||
// If player is ready, add directly via srtControl
|
||||
if let srtControl = playerView?.srtControl {
|
||||
let subtitleInfo = URLSubtitleInfo(url: subUrl)
|
||||
srtControl.addSubtitle(info: subtitleInfo)
|
||||
print("[SfPlayer] Added subtitle via srtControl: \(subtitleInfo.name)")
|
||||
if select {
|
||||
srtControl.selectedSubtitleInfo = subtitleInfo
|
||||
playerView?.updateSrt()
|
||||
print("[SfPlayer] Selected subtitle: \(subtitleInfo.name)")
|
||||
}
|
||||
} else {
|
||||
// Player not ready yet, queue for later
|
||||
print("[SfPlayer] Player not ready, queuing subtitle")
|
||||
pendingExternalSubtitles.append(url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
func setSubtitlePosition(_ position: Int) {
|
||||
// KSPlayer subtitle positioning through options
|
||||
}
|
||||
|
||||
func setSubtitleScale(_ scale: Double) {
|
||||
subtitleScale = CGFloat(scale)
|
||||
isScaleExplicitlySet = true
|
||||
applySubtitleScale()
|
||||
}
|
||||
|
||||
private func applySubtitleScale() {
|
||||
guard let subtitleBackView = playerView?.subtitleBackView else { return }
|
||||
|
||||
// Apply scale transform to subtitle view
|
||||
// This scales both text and image-based subtitles (PGS, VOBSUB)
|
||||
subtitleBackView.transform = CGAffineTransform(scaleX: subtitleScale, y: subtitleScale)
|
||||
}
|
||||
|
||||
func setSubtitleMarginY(_ margin: Int) {
|
||||
var position = SubtitleModel.textPosition
|
||||
position.verticalMargin = CGFloat(margin)
|
||||
SubtitleModel.textPosition = position
|
||||
playerView?.updateSrt()
|
||||
}
|
||||
|
||||
func setSubtitleAlignX(_ alignment: String) {
|
||||
var position = SubtitleModel.textPosition
|
||||
switch alignment.lowercased() {
|
||||
case "left":
|
||||
position.horizontalAlign = .leading
|
||||
case "right":
|
||||
position.horizontalAlign = .trailing
|
||||
default:
|
||||
position.horizontalAlign = .center
|
||||
}
|
||||
SubtitleModel.textPosition = position
|
||||
playerView?.updateSrt()
|
||||
}
|
||||
|
||||
func setSubtitleAlignY(_ alignment: String) {
|
||||
var position = SubtitleModel.textPosition
|
||||
switch alignment.lowercased() {
|
||||
case "top":
|
||||
position.verticalAlign = .top
|
||||
case "center":
|
||||
position.verticalAlign = .center
|
||||
default:
|
||||
position.verticalAlign = .bottom
|
||||
}
|
||||
SubtitleModel.textPosition = position
|
||||
playerView?.updateSrt()
|
||||
}
|
||||
|
||||
func setSubtitleFontSize(_ size: Int) {
|
||||
// Size is now a scale value * 100 (e.g., 100 = 1.0, 60 = 0.6)
|
||||
// Convert to actual scale for both text and image subtitles
|
||||
let scale = CGFloat(size) / 100.0
|
||||
|
||||
// Set font size for text-based subtitles (SRT, ASS, VTT)
|
||||
// Base font size ~50pt, scaled by user preference
|
||||
SubtitleModel.textFontSize = 50.0 * scale
|
||||
|
||||
// Apply scale for image-based subtitles (PGS, VOBSUB)
|
||||
// Only if scale wasn't explicitly set via setSubtitleScale
|
||||
if !isScaleExplicitlySet {
|
||||
subtitleScale = min(max(scale, 0.3), 1.5) // Clamp to 0.3-1.5
|
||||
applySubtitleScale()
|
||||
}
|
||||
|
||||
playerView?.updateSrt()
|
||||
}
|
||||
|
||||
func setSubtitleFontName(_ name: String?) {
|
||||
subtitleFontName = name
|
||||
applySubtitleFont()
|
||||
}
|
||||
|
||||
func setSubtitleColor(_ hexColor: String) {
|
||||
if let color = UIColor(hex: hexColor) {
|
||||
SubtitleModel.textColor = Color(color)
|
||||
playerView?.subtitleLabel.textColor = color
|
||||
playerView?.updateSrt()
|
||||
}
|
||||
}
|
||||
|
||||
func setSubtitleBackgroundColor(_ hexColor: String) {
|
||||
if let color = UIColor(hex: hexColor) {
|
||||
SubtitleModel.textBackgroundColor = Color(color)
|
||||
playerView?.subtitleBackView.backgroundColor = color
|
||||
playerView?.updateSrt()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hardware Decode
|
||||
|
||||
static func setHardwareDecode(_ enabled: Bool) {
|
||||
KSOptions.hardwareDecode = enabled
|
||||
}
|
||||
|
||||
static func getHardwareDecode() -> Bool {
|
||||
return KSOptions.hardwareDecode
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func applySubtitleFont() {
|
||||
guard let playerView else { return }
|
||||
let currentSize = playerView.subtitleLabel.font.pointSize
|
||||
|
||||
let baseFont: UIFont
|
||||
if let subtitleFontName,
|
||||
!subtitleFontName.isEmpty,
|
||||
subtitleFontName.lowercased() != "system",
|
||||
let customFont = UIFont(name: subtitleFontName, size: currentSize) {
|
||||
baseFont = customFont
|
||||
} else {
|
||||
baseFont = UIFont.systemFont(ofSize: currentSize)
|
||||
}
|
||||
|
||||
// Remove any implicit italic trait to avoid overly slanted rendering
|
||||
let nonItalicDescriptor = baseFont.fontDescriptor
|
||||
.withSymbolicTraits(baseFont.fontDescriptor.symbolicTraits.subtracting(.traitItalic))
|
||||
?? baseFont.fontDescriptor
|
||||
let finalFont = UIFont(descriptor: nonItalicDescriptor, size: currentSize)
|
||||
|
||||
playerView.subtitleLabel.font = finalFont
|
||||
playerView.updateSrt()
|
||||
}
|
||||
|
||||
// MARK: - Audio Controls
|
||||
|
||||
func getAudioTracks() -> [[String: Any]] {
|
||||
guard let player = playerView?.playerLayer?.player else { return [] }
|
||||
|
||||
var tracks: [[String: Any]] = []
|
||||
let audioTracks = player.tracks(mediaType: .audio)
|
||||
|
||||
for (index, track) in audioTracks.enumerated() {
|
||||
let trackInfo: [String: Any] = [
|
||||
"id": index + 1,
|
||||
"selected": track.isEnabled,
|
||||
"title": track.name,
|
||||
"lang": track.language ?? ""
|
||||
]
|
||||
tracks.append(trackInfo)
|
||||
}
|
||||
|
||||
return tracks
|
||||
}
|
||||
|
||||
func setAudioTrack(_ trackId: Int) {
|
||||
guard let player = playerView?.playerLayer?.player else { return }
|
||||
|
||||
let audioTracks = player.tracks(mediaType: .audio)
|
||||
let index = trackId - 1
|
||||
|
||||
if index >= 0 && index < audioTracks.count {
|
||||
let track = audioTracks[index]
|
||||
player.select(track: track)
|
||||
}
|
||||
}
|
||||
|
||||
func getCurrentAudioTrack() -> Int {
|
||||
guard let player = playerView?.playerLayer?.player else { return 0 }
|
||||
|
||||
let audioTracks = player.tracks(mediaType: .audio)
|
||||
for (index, track) in audioTracks.enumerated() {
|
||||
if track.isEnabled {
|
||||
return index + 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// MARK: - Video Zoom
|
||||
|
||||
func setVideoZoomToFill(_ enabled: Bool) {
|
||||
// Toggle between fit (black bars) and fill (crop to fill screen)
|
||||
let contentMode: UIView.ContentMode = enabled ? .scaleAspectFill : .scaleAspectFit
|
||||
playerView?.playerLayer?.player.view?.contentMode = contentMode
|
||||
}
|
||||
|
||||
func getVideoZoomToFill() -> Bool {
|
||||
return playerView?.playerLayer?.player.view?.contentMode == .scaleAspectFill
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
func updateLayout(bounds: CGRect) {
|
||||
containerView?.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PlayerControllerDelegate
|
||||
|
||||
extension SfPlayerWrapper: PlayerControllerDelegate {
|
||||
func playerController(state: KSPlayerState) {
|
||||
switch state {
|
||||
case .initialized:
|
||||
break
|
||||
|
||||
case .preparing:
|
||||
isLoading = true
|
||||
delegate?.player(self, didChangeLoading: true)
|
||||
|
||||
case .readyToPlay:
|
||||
isLoading = false
|
||||
delegate?.player(self, didChangeLoading: false)
|
||||
delegate?.player(self, didBecomeReadyToSeek: true)
|
||||
delegate?.player(self, didBecomeTracksReady: true)
|
||||
|
||||
// Seek to pending start position if set
|
||||
// Pause first, seek, then resume to avoid showing video at wrong position
|
||||
if let startPos = pendingStartPosition, startPos > 0 {
|
||||
let capturedStartPos = startPos
|
||||
let wasPlaying = !isPaused
|
||||
pendingStartPosition = nil
|
||||
|
||||
// Pause to prevent showing frames at wrong position
|
||||
playerView?.pause()
|
||||
|
||||
// Small delay then seek
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.playerView?.seek(time: capturedStartPos) { [weak self] finished in
|
||||
guard let self else { return }
|
||||
if finished && wasPlaying {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Center video content - KSAVPlayerView maps contentMode to videoGravity
|
||||
playerView?.playerLayer?.player.view?.contentMode = .scaleAspectFit
|
||||
|
||||
// Setup PiP controller with delegate
|
||||
setupPictureInPicture()
|
||||
|
||||
// Add embedded subtitles from player to srtControl
|
||||
// This makes them available for selection and rendering via srtControl
|
||||
if let player = playerView?.playerLayer?.player,
|
||||
let subtitleDataSource = player.subtitleDataSouce {
|
||||
print("[SfPlayer] Adding embedded subtitles from player.subtitleDataSouce")
|
||||
playerView?.srtControl.addSubtitle(dataSouce: subtitleDataSource)
|
||||
}
|
||||
|
||||
// Load pending external subtitles via srtControl
|
||||
print("[SfPlayer] readyToPlay - Loading \(pendingExternalSubtitles.count) external subtitles")
|
||||
for subUrlString in pendingExternalSubtitles {
|
||||
print("[SfPlayer] Adding external subtitle: \(subUrlString)")
|
||||
if let subUrl = URL(string: subUrlString) {
|
||||
let subtitleInfo = URLSubtitleInfo(url: subUrl)
|
||||
playerView?.srtControl.addSubtitle(info: subtitleInfo)
|
||||
print("[SfPlayer] Added subtitle info: \(subtitleInfo.name)")
|
||||
} else {
|
||||
print("[SfPlayer] Failed to create URL from: \(subUrlString)")
|
||||
}
|
||||
}
|
||||
pendingExternalSubtitles.removeAll()
|
||||
|
||||
// Log all available subtitles in srtControl
|
||||
let allSubtitles = playerView?.srtControl.subtitleInfos ?? []
|
||||
print("[SfPlayer] srtControl now has \(allSubtitles.count) subtitles:")
|
||||
for (i, info) in allSubtitles.enumerated() {
|
||||
print("[SfPlayer] [\(i)]: \(info.name)")
|
||||
}
|
||||
|
||||
// Also log embedded tracks from player for reference
|
||||
let embeddedTracks = playerView?.playerLayer?.player.tracks(mediaType: .subtitle) ?? []
|
||||
print("[SfPlayer] player.tracks has \(embeddedTracks.count) embedded tracks")
|
||||
|
||||
// Apply initial track selection
|
||||
print("[SfPlayer] Applying initial track selections - subId: \(String(describing: initialSubtitleId)), audioId: \(String(describing: initialAudioId))")
|
||||
if let subId = initialSubtitleId {
|
||||
if subId < 0 {
|
||||
print("[SfPlayer] Disabling subtitles (subId < 0)")
|
||||
disableSubtitles()
|
||||
} else {
|
||||
print("[SfPlayer] Setting subtitle track to: \(subId)")
|
||||
setSubtitleTrack(subId)
|
||||
}
|
||||
}
|
||||
if let audioId = initialAudioId {
|
||||
print("[SfPlayer] Setting audio track to: \(audioId)")
|
||||
setAudioTrack(audioId)
|
||||
}
|
||||
|
||||
// Debug: Check selected subtitle after applying
|
||||
if let selectedSub = playerView?.srtControl.selectedSubtitleInfo {
|
||||
print("[SfPlayer] Currently selected subtitle: \(selectedSub.name)")
|
||||
} else {
|
||||
print("[SfPlayer] No subtitle currently selected in srtControl")
|
||||
}
|
||||
|
||||
case .buffering:
|
||||
isLoading = true
|
||||
delegate?.player(self, didChangeLoading: true)
|
||||
|
||||
case .bufferFinished:
|
||||
isLoading = false
|
||||
delegate?.player(self, didChangeLoading: false)
|
||||
|
||||
case .paused:
|
||||
isPaused = true
|
||||
delegate?.player(self, didChangePause: true)
|
||||
|
||||
case .playedToTheEnd:
|
||||
isPaused = true
|
||||
delegate?.player(self, didChangePause: true)
|
||||
stopProgressTimer()
|
||||
|
||||
case .error:
|
||||
delegate?.player(self, didEncounterError: "Playback error occurred")
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func playerController(currentTime: TimeInterval, totalTime: TimeInterval) {
|
||||
cachedPosition = currentTime
|
||||
cachedDuration = totalTime
|
||||
delegate?.player(self, didUpdatePosition: currentTime, duration: totalTime)
|
||||
}
|
||||
|
||||
func playerController(finish error: Error?) {
|
||||
if let error = error {
|
||||
delegate?.player(self, didEncounterError: error.localizedDescription)
|
||||
}
|
||||
stopProgressTimer()
|
||||
}
|
||||
|
||||
func playerController(maskShow: Bool) {
|
||||
// UI mask visibility changed
|
||||
}
|
||||
|
||||
func playerController(action: PlayerButtonType) {
|
||||
// Button action handled
|
||||
}
|
||||
|
||||
func playerController(bufferedCount: Int, consumeTime: TimeInterval) {
|
||||
// Buffering progress
|
||||
}
|
||||
|
||||
func playerController(seek: TimeInterval) {
|
||||
// Seek completed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureControllerDelegate
|
||||
|
||||
extension SfPlayerWrapper: AVPictureInPictureControllerDelegate {
|
||||
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
delegate?.player(self, didChangePictureInPicture: true)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
delegate?.player(self, didChangePictureInPicture: false)
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
||||
delegate?.player(self, didEncounterError: "PiP failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
||||
// Called when user taps to restore from PiP - return true to allow restoration
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIColor Hex Extension
|
||||
|
||||
extension UIColor {
|
||||
convenience init?(hex: String) {
|
||||
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
||||
|
||||
var rgb: UInt64 = 0
|
||||
var r: CGFloat = 0.0
|
||||
var g: CGFloat = 0.0
|
||||
var b: CGFloat = 0.0
|
||||
var a: CGFloat = 1.0
|
||||
|
||||
let length = hexSanitized.count
|
||||
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
|
||||
|
||||
if length == 6 {
|
||||
r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
|
||||
g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
|
||||
b = CGFloat(rgb & 0x0000FF) / 255.0
|
||||
} else if length == 8 {
|
||||
r = CGFloat((rgb & 0xFF000000) >> 24) / 255.0
|
||||
g = CGFloat((rgb & 0x00FF0000) >> 16) / 255.0
|
||||
b = CGFloat((rgb & 0x0000FF00) >> 8) / 255.0
|
||||
a = CGFloat(rgb & 0x000000FF) / 255.0
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.init(red: r, green: g, blue: b, alpha: a)
|
||||
}
|
||||
}
|
||||
111
modules/sf-player/src/SfPlayer.types.ts
Normal file
111
modules/sf-player/src/SfPlayer.types.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export type OnErrorEventPayload = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||
|
||||
export type OnPictureInPictureChangePayload = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type VideoSource = {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
externalSubtitles?: string[];
|
||||
startPosition?: number;
|
||||
autoplay?: boolean;
|
||||
/** Subtitle track ID to select on start (1-based, -1 to disable) */
|
||||
initialSubtitleId?: number;
|
||||
/** Audio track ID to select on start (1-based) */
|
||||
initialAudioId?: number;
|
||||
};
|
||||
|
||||
export type SfPlayerViewProps = {
|
||||
source?: VideoSource;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
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 SfPlayerViewRef {
|
||||
play: () => Promise<void>;
|
||||
pause: () => 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>;
|
||||
setAutoPipEnabled: (enabled: boolean) => Promise<void>;
|
||||
// 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>;
|
||||
setSubtitleColor: (hexColor: string) => Promise<void>;
|
||||
setSubtitleBackgroundColor: (hexColor: string) => Promise<void>;
|
||||
setSubtitleFontName: (fontName: string) => Promise<void>;
|
||||
// Audio controls
|
||||
getAudioTracks: () => Promise<AudioTrack[]>;
|
||||
setAudioTrack: (trackId: number) => Promise<void>;
|
||||
getCurrentAudioTrack: () => Promise<number>;
|
||||
// Video zoom
|
||||
setVideoZoomToFill: (enabled: boolean) => Promise<void>;
|
||||
getVideoZoomToFill: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
120
modules/sf-player/src/SfPlayerView.tsx
Normal file
120
modules/sf-player/src/SfPlayerView.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { requireNativeView } from "expo";
|
||||
import * as React from "react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
|
||||
import { SfPlayerViewProps, SfPlayerViewRef } from "./SfPlayer.types";
|
||||
|
||||
const NativeView: React.ComponentType<SfPlayerViewProps & { ref?: any }> =
|
||||
requireNativeView("SfPlayer");
|
||||
|
||||
export default React.forwardRef<SfPlayerViewRef, SfPlayerViewProps>(
|
||||
function SfPlayerView(props, ref) {
|
||||
const nativeRef = useRef<any>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
play: async () => {
|
||||
await nativeRef.current?.play();
|
||||
},
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
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()) ?? 1.0;
|
||||
},
|
||||
isPaused: async () => {
|
||||
return (await nativeRef.current?.isPaused()) ?? true;
|
||||
},
|
||||
getCurrentPosition: async () => {
|
||||
return (await nativeRef.current?.getCurrentPosition()) ?? 0;
|
||||
},
|
||||
getDuration: async () => {
|
||||
return (await nativeRef.current?.getDuration()) ?? 0;
|
||||
},
|
||||
startPictureInPicture: async () => {
|
||||
await nativeRef.current?.startPictureInPicture();
|
||||
},
|
||||
stopPictureInPicture: async () => {
|
||||
await nativeRef.current?.stopPictureInPicture();
|
||||
},
|
||||
isPictureInPictureSupported: async () => {
|
||||
return (
|
||||
(await nativeRef.current?.isPictureInPictureSupported()) ?? false
|
||||
);
|
||||
},
|
||||
isPictureInPictureActive: async () => {
|
||||
return (await nativeRef.current?.isPictureInPictureActive()) ?? false;
|
||||
},
|
||||
setAutoPipEnabled: async (enabled: boolean) => {
|
||||
await nativeRef.current?.setAutoPipEnabled(enabled);
|
||||
},
|
||||
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()) ?? 0;
|
||||
},
|
||||
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);
|
||||
},
|
||||
setSubtitleColor: async (hexColor: string) => {
|
||||
await nativeRef.current?.setSubtitleColor(hexColor);
|
||||
},
|
||||
setSubtitleBackgroundColor: async (hexColor: string) => {
|
||||
await nativeRef.current?.setSubtitleBackgroundColor(hexColor);
|
||||
},
|
||||
setSubtitleFontName: async (fontName: string) => {
|
||||
await nativeRef.current?.setSubtitleFontName?.(fontName);
|
||||
},
|
||||
getAudioTracks: async () => {
|
||||
return (await nativeRef.current?.getAudioTracks()) ?? [];
|
||||
},
|
||||
setAudioTrack: async (trackId: number) => {
|
||||
await nativeRef.current?.setAudioTrack(trackId);
|
||||
},
|
||||
getCurrentAudioTrack: async () => {
|
||||
return (await nativeRef.current?.getCurrentAudioTrack()) ?? 0;
|
||||
},
|
||||
setVideoZoomToFill: async (enabled: boolean) => {
|
||||
await nativeRef.current?.setVideoZoomToFill(enabled);
|
||||
},
|
||||
getVideoZoomToFill: async () => {
|
||||
return (await nativeRef.current?.getVideoZoomToFill()) ?? false;
|
||||
},
|
||||
}));
|
||||
|
||||
return <NativeView ref={nativeRef} {...props} />;
|
||||
},
|
||||
);
|
||||
15
modules/sf-player/src/index.ts
Normal file
15
modules/sf-player/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
|
||||
export * from "./SfPlayer.types";
|
||||
export { default as SfPlayerView } from "./SfPlayerView";
|
||||
|
||||
// Module-level functions for global KSPlayer settings
|
||||
const SfPlayerModule = requireNativeModule("SfPlayer");
|
||||
|
||||
export function setHardwareDecode(enabled: boolean): void {
|
||||
SfPlayerModule.setHardwareDecode(enabled);
|
||||
}
|
||||
|
||||
export function getHardwareDecode(): boolean {
|
||||
return SfPlayerModule.getHardwareDecode();
|
||||
}
|
||||
Reference in New Issue
Block a user