mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
477 lines
15 KiB
Swift
477 lines
15 KiB
Swift
import ExpoModulesCore
|
|
import UIKit
|
|
import VLCKit
|
|
import os
|
|
|
|
|
|
public class VLCPlayerView: UIView {
|
|
func setupView(parent: UIView) {
|
|
self.backgroundColor = .black
|
|
self.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
self.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
|
|
self.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
|
|
self.topAnchor.constraint(equalTo: parent.topAnchor),
|
|
self.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
|
])
|
|
}
|
|
|
|
public override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
for subview in subviews {
|
|
subview.frame = bounds
|
|
}
|
|
}
|
|
}
|
|
|
|
class VLCPlayerWrapper: NSObject {
|
|
private var lastProgressCall = Date().timeIntervalSince1970
|
|
public var player: VLCMediaPlayer = VLCMediaPlayer()
|
|
private var updatePlayerState: (() -> Void)?
|
|
private var updateVideoProgress: (() -> Void)?
|
|
private var playerView: VLCPlayerView = VLCPlayerView()
|
|
public weak var pipController: VLCPictureInPictureWindowControlling?
|
|
|
|
override public init() {
|
|
super.init()
|
|
player.delegate = self
|
|
player.drawable = self
|
|
player.scaleFactor = 0
|
|
}
|
|
|
|
public func setup(
|
|
parent: UIView,
|
|
updatePlayerState: (() -> Void)?,
|
|
updateVideoProgress: (() -> Void)?
|
|
) {
|
|
self.updatePlayerState = updatePlayerState
|
|
self.updateVideoProgress = updateVideoProgress
|
|
|
|
player.delegate = self
|
|
parent.addSubview(playerView)
|
|
playerView.setupView(parent: parent)
|
|
}
|
|
|
|
public func getPlayerView() -> UIView {
|
|
return playerView
|
|
}
|
|
}
|
|
|
|
// MARK: - VLCPictureInPictureDrawable
|
|
extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
|
|
public func mediaController() -> (any VLCPictureInPictureMediaControlling)! {
|
|
return self
|
|
}
|
|
|
|
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
|
|
{
|
|
return { [weak self] controller in
|
|
self?.pipController = controller
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - VLCPictureInPictureMediaControlling
|
|
extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
|
|
func mediaTime() -> Int64 {
|
|
return player.time.value?.int64Value ?? 0
|
|
}
|
|
|
|
func mediaLength() -> Int64 {
|
|
return player.media?.length.value?.int64Value ?? 0
|
|
}
|
|
|
|
func play() {
|
|
player.play()
|
|
}
|
|
|
|
func pause() {
|
|
player.pause()
|
|
}
|
|
|
|
func seek(by offset: Int64, completion: @escaping () -> Void) {
|
|
player.jump(withOffset: Int32(offset), completion: completion)
|
|
}
|
|
|
|
func isMediaSeekable() -> Bool {
|
|
return player.isSeekable
|
|
}
|
|
|
|
func isMediaPlaying() -> Bool {
|
|
return player.isPlaying
|
|
}
|
|
}
|
|
|
|
// MARK: - VLCDrawable
|
|
extension VLCPlayerWrapper: VLCDrawable {
|
|
public func addSubview(_ view: UIView) {
|
|
playerView.addSubview(view)
|
|
}
|
|
|
|
public func bounds() -> CGRect {
|
|
return playerView.bounds
|
|
}
|
|
}
|
|
|
|
// MARK: - VLCMediaPlayerDelegate
|
|
extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
|
|
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
let timeNow = Date().timeIntervalSince1970
|
|
if timeNow - self.lastProgressCall >= 1 {
|
|
self.lastProgressCall = timeNow
|
|
self.updateVideoProgress?()
|
|
}
|
|
}
|
|
}
|
|
|
|
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
self.updatePlayerState?()
|
|
|
|
guard let pipController = self.pipController else { return }
|
|
pipController.invalidatePlaybackState()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - VLCMediaDelegate
|
|
extension VLCPlayerWrapper: VLCMediaDelegate {
|
|
// Implement VLCMediaDelegate methods if needed
|
|
}
|
|
|
|
class VlcPlayerView: ExpoView {
|
|
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView")
|
|
|
|
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
|
|
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
|
private var isPaused: Bool = false
|
|
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
|
private var startPosition: Int32 = 0
|
|
private var externalTrack: [String: String]?
|
|
private var isStopping: Bool = false // Define isStopping here
|
|
private var externalSubtitles: [[String: String]]?
|
|
var hasSource = false
|
|
|
|
// MARK: - Initialization
|
|
required init(appContext: AppContext? = nil) {
|
|
super.init(appContext: appContext)
|
|
setupVLC()
|
|
setupNotifications()
|
|
VLCManager.shared.listeners.append(self)
|
|
}
|
|
|
|
// MARK: - Setup
|
|
private func setupVLC() {
|
|
vlc.setup(
|
|
parent: self,
|
|
updatePlayerState: updatePlayerState,
|
|
updateVideoProgress: updateVideoProgress
|
|
)
|
|
}
|
|
|
|
private func setupNotifications() {
|
|
NotificationCenter.default.addObserver(
|
|
self, selector: #selector(applicationWillResignActive),
|
|
name: UIApplication.willResignActiveNotification, object: nil)
|
|
NotificationCenter.default.addObserver(
|
|
self, selector: #selector(applicationDidBecomeActive),
|
|
name: UIApplication.didBecomeActiveNotification, object: nil)
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
func startPictureInPicture() {
|
|
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
|
|
self.onPipStarted?(["pipStarted": isStarted])
|
|
}
|
|
self.vlc.pipController?.startPictureInPicture()
|
|
}
|
|
|
|
@objc func play() {
|
|
self.vlc.player.play()
|
|
self.isPaused = false
|
|
logger.debug("Play")
|
|
}
|
|
|
|
@objc func pause() {
|
|
self.vlc.player.pause()
|
|
self.isPaused = true
|
|
}
|
|
|
|
@objc func seekTo(_ time: Int32) {
|
|
let wasPlaying = vlc.player.isPlaying
|
|
if wasPlaying {
|
|
self.pause()
|
|
}
|
|
|
|
if let duration = vlc.player.media?.length.intValue {
|
|
logger.debug("Seeking to time: \(time) Video Duration \(duration)")
|
|
|
|
// If the specified time is greater than the duration, seek to the end
|
|
let seekTime = time > duration ? duration - 1000 : time
|
|
vlc.player.time = VLCTime(int: seekTime)
|
|
self.updatePlayerState()
|
|
|
|
// Let mediaPlayerStateChanged handle play state change
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
if wasPlaying {
|
|
self.play()
|
|
}
|
|
}
|
|
} else {
|
|
logger.error("Unable to retrieve video duration")
|
|
}
|
|
}
|
|
|
|
@objc func setSource(_ source: [String: Any]) {
|
|
logger.debug("Setting source...")
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
if self.hasSource {
|
|
return
|
|
}
|
|
|
|
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
|
self.externalTrack = source["externalTrack"] as? [String: String]
|
|
let initOptions: [String] = source["initOptions"] as? [String] ?? []
|
|
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
|
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
|
|
|
for item in initOptions {
|
|
let option = item.components(separatedBy: "=")
|
|
mediaOptions.updateValue(
|
|
option[1], forKey: option[0].replacingOccurrences(of: "--", with: ""))
|
|
}
|
|
|
|
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
|
logger.error("Invalid or empty URI")
|
|
self.onVideoError?(["error": "Invalid or empty URI"])
|
|
return
|
|
}
|
|
|
|
let autoplay = source["autoplay"] as? Bool ?? false
|
|
let isNetwork = source["isNetwork"] as? Bool ?? false
|
|
|
|
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
|
|
|
let media: VLCMedia!
|
|
if isNetwork {
|
|
logger.debug("Loading network file: \(uri)")
|
|
media = VLCMedia(url: URL(string: uri)!)
|
|
} else {
|
|
logger.debug("Loading local file: \(uri)")
|
|
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
|
media = VLCMedia(url: url)
|
|
} else {
|
|
media = VLCMedia(path: uri)
|
|
}
|
|
}
|
|
|
|
logger.debug("Media options: \(mediaOptions)")
|
|
media.addOptions(mediaOptions)
|
|
|
|
self.vlc.player.media = media
|
|
self.setInitialExternalSubtitles()
|
|
self.hasSource = true
|
|
if autoplay {
|
|
logger.info("Playing...")
|
|
self.play()
|
|
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func setAudioTrack(_ trackIndex: Int) {
|
|
print("Setting audio track: \(trackIndex)")
|
|
let track = self.vlc.player.audioTracks[trackIndex]
|
|
track.isSelectedExclusively = true
|
|
}
|
|
|
|
@objc func getAudioTracks() -> [[String: Any]]? {
|
|
return vlc.player.audioTracks.enumerated().map {
|
|
return ["name": $1.trackName, "index": $0]
|
|
}
|
|
}
|
|
|
|
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
|
logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
|
|
if trackIndex == -1 {
|
|
logger.debug("Disabling all subtitles")
|
|
for track in self.vlc.player.textTracks {
|
|
track.isSelected = false
|
|
}
|
|
return
|
|
}
|
|
let track = self.vlc.player.textTracks[trackIndex]
|
|
track.isSelectedExclusively = true;
|
|
logger.debug("Current subtitle track index after setting: \(track.trackName)")
|
|
}
|
|
|
|
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
|
guard let url = URL(string: subtitleURL) else {
|
|
logger.error("Invalid subtitle URL")
|
|
return
|
|
}
|
|
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
|
if result == 0 {
|
|
let internalName = "Track \(self.customSubtitles.count)"
|
|
self.customSubtitles.append((internalName: internalName, originalName: name))
|
|
logger.debug("Subtitle added with result: \(result) \(internalName)")
|
|
} else {
|
|
logger.debug("Failed to add subtitle")
|
|
}
|
|
}
|
|
|
|
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
|
if self.vlc.player.textTracks.count == 0 {
|
|
return nil
|
|
}
|
|
|
|
logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
|
|
|
|
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
|
|
if let customSubtitle = customSubtitles.first(where: {
|
|
$0.internalName == track.trackName
|
|
}) {
|
|
return ["name": customSubtitle.originalName, "index": index]
|
|
} else {
|
|
return ["name": track.trackName, "index": index]
|
|
}
|
|
}
|
|
|
|
logger.debug("Subtitle tracks: \(tracks)")
|
|
return tracks
|
|
}
|
|
|
|
@objc func stop(completion: (() -> Void)? = nil) {
|
|
logger.debug("Stopping media...")
|
|
guard !isStopping else {
|
|
completion?()
|
|
return
|
|
}
|
|
isStopping = true
|
|
|
|
// If we're not on the main thread, dispatch to main thread
|
|
if !Thread.isMainThread {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.performStop(completion: completion)
|
|
}
|
|
} else {
|
|
performStop(completion: completion)
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
@objc private func applicationWillResignActive() {
|
|
|
|
}
|
|
|
|
@objc private func applicationDidBecomeActive() {
|
|
|
|
}
|
|
|
|
private func setInitialExternalSubtitles() {
|
|
if let externalSubtitles = self.externalSubtitles {
|
|
for subtitle in externalSubtitles {
|
|
if let subtitleName = subtitle["name"],
|
|
let subtitleURL = subtitle["DeliveryUrl"]
|
|
{
|
|
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
|
|
self.setSubtitleURL(subtitleURL, name: subtitleName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func performStop(completion: (() -> Void)? = nil) {
|
|
// Stop the media player
|
|
vlc.player.stop()
|
|
|
|
// Remove observer
|
|
NotificationCenter.default.removeObserver(self)
|
|
|
|
// Clear the video view
|
|
vlc.getPlayerView().removeFromSuperview()
|
|
|
|
isStopping = false
|
|
completion?()
|
|
}
|
|
|
|
private func updateVideoProgress() {
|
|
guard let media = self.vlc.player.media else { return }
|
|
|
|
let currentTimeMs = self.vlc.player.time.intValue
|
|
let durationMs = self.vlc.player.media?.length.intValue ?? 0
|
|
|
|
logger.debug("Current time: \(currentTimeMs)")
|
|
self.onVideoProgress?([
|
|
"currentTime": currentTimeMs,
|
|
"duration": durationMs,
|
|
])
|
|
}
|
|
|
|
private func updatePlayerState() {
|
|
let player = self.vlc.player
|
|
self.onVideoStateChange?([
|
|
"target": self.reactTag ?? NSNull(),
|
|
"currentTime": player.time.intValue,
|
|
"duration": player.media?.length.intValue ?? 0,
|
|
"error": false,
|
|
"isPlaying": player.isPlaying,
|
|
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
|
|
"state": player.state.description,
|
|
])
|
|
}
|
|
|
|
// MARK: - Expo Events
|
|
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
|
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
|
@objc var onVideoStateChange: RCTDirectEventBlock?
|
|
@objc var onVideoProgress: RCTDirectEventBlock?
|
|
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
|
@objc var onVideoError: RCTDirectEventBlock?
|
|
@objc var onPipStarted: RCTDirectEventBlock?
|
|
|
|
// MARK: - Deinitialization
|
|
|
|
deinit {
|
|
logger.debug("Deinitialization")
|
|
performStop()
|
|
VLCManager.shared.listeners.removeAll()
|
|
}
|
|
}
|
|
|
|
// MARK: - SimpleAppLifecycleListener
|
|
extension VlcPlayerView: SimpleAppLifecycleListener {
|
|
func applicationDidEnterBackground() {
|
|
logger.debug("Entering background")
|
|
}
|
|
|
|
func applicationDidEnterForeground() {
|
|
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
|
|
if !self.vlc.getPlayerView().isDescendant(of: self) {
|
|
logger.debug("Player view is missing. Adding back as subview")
|
|
self.addSubview(self.vlc.getPlayerView())
|
|
}
|
|
self.layoutIfNeeded()
|
|
}
|
|
}
|
|
|
|
extension VLCMediaPlayerState {
|
|
var description: String {
|
|
switch self {
|
|
case .opening: return "Opening"
|
|
case .buffering: return "Buffering"
|
|
case .playing: return "Playing"
|
|
case .paused: return "Paused"
|
|
case .stopped: return "Stopped"
|
|
case .error: return "Error"
|
|
@unknown default: return "Unknown"
|
|
}
|
|
}
|
|
}
|