feat: KSPlayer as an option for iOS + other improvements (#1266)

This commit is contained in:
Fredrik Burmester
2026-01-03 13:05:50 +01:00
committed by GitHub
parent d1795c9df8
commit 74d86b5d12
191 changed files with 88479 additions and 2316 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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";

View 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
}
}

View File

@@ -0,0 +1,2 @@
<manifest>
</manifest>

View File

@@ -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")
}
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,6 @@
{
"platforms": ["android", "web"],
"android": {
"modules": ["expo.modules.mpvplayer.MpvPlayerModule"]
}
}

View 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";

View 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
}
}

File diff suppressed because it is too large Load Diff

View 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

View 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")
}
}
}

View 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()
}
}

View 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()
}
}

View 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
}
}

View 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
}
}

View 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;
};

View 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");

View 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");

View 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} />;
},
);

View 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>
);
}

View File

@@ -0,0 +1,3 @@
export * from "./MpvPlayer.types";
export { default as MpvPlayerModule } from "./MpvPlayerModule";
export { default as MpvPlayerView } from "./MpvPlayerView";

View 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
}

View File

@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -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")
}
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,9 @@
{
"platforms": ["ios", "tvos", "android"],
"ios": {
"modules": ["SfPlayerModule"]
},
"android": {
"modules": ["expo.modules.sfplayer.SfPlayerModule"]
}
}

View File

@@ -0,0 +1 @@
export * from "./src";

View 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

View 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")
}
}
}

View 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])
}
}
}

View 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)
}
}

View 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;
};

View 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} />;
},
);

View 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();
}