mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
449 lines
14 KiB
Swift
449 lines
14 KiB
Swift
import ExpoModulesCore
|
|
import MobileVLCKit
|
|
import UIKit
|
|
|
|
class VlcPlayerView: ExpoView {
|
|
private var mediaPlayer: VLCMediaPlayer?
|
|
private var videoView: UIView?
|
|
private var progressUpdateTimer: Timer?
|
|
private var progressUpdateInterval: TimeInterval = 0.5
|
|
private var isPaused: Bool = false
|
|
private var currentGeometryCString: [CChar]?
|
|
private var stateUpdateTimer: Timer?
|
|
private var lastReportedState: VLCMediaPlayerState?
|
|
|
|
// MARK: - Initialization
|
|
|
|
required init(appContext: AppContext? = nil) {
|
|
super.init(appContext: appContext)
|
|
setupView()
|
|
setupNotifications()
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setupView() {
|
|
DispatchQueue.main.async {
|
|
self.backgroundColor = .black
|
|
self.videoView = UIView()
|
|
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
if let videoView = self.videoView {
|
|
self.addSubview(videoView)
|
|
NSLayoutConstraint.activate([
|
|
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
|
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
|
videoView.topAnchor.constraint(equalTo: self.topAnchor),
|
|
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
|
])
|
|
}
|
|
|
|
self.setupMediaPlayer()
|
|
}
|
|
}
|
|
|
|
private func setupMediaPlayer() {
|
|
DispatchQueue.main.async {
|
|
self.mediaPlayer = VLCMediaPlayer()
|
|
self.mediaPlayer?.delegate = self
|
|
self.mediaPlayer?.drawable = self.videoView
|
|
}
|
|
}
|
|
|
|
private func setupNotifications() {
|
|
NotificationCenter.default.addObserver(
|
|
self, selector: #selector(applicationWillResignActive),
|
|
name: UIApplication.willResignActiveNotification, object: nil)
|
|
NotificationCenter.default.addObserver(
|
|
self, selector: #selector(applicationWillEnterForeground),
|
|
name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
@objc func play() {
|
|
self.mediaPlayer?.play()
|
|
self.isPaused = false
|
|
}
|
|
|
|
@objc func pause() {
|
|
self.mediaPlayer?.pause()
|
|
self.isPaused = true
|
|
}
|
|
|
|
@objc func seekTo(_ time: Double) {
|
|
self.mediaPlayer?.time = VLCTime(int: Int32(time * 1000))
|
|
}
|
|
|
|
@objc func setSource(_ source: [String: Any]) {
|
|
DispatchQueue.main.async {
|
|
self.mediaPlayer?.stop()
|
|
self.mediaPlayer = nil
|
|
|
|
let mediaOptions = source["mediaOptions"] as? [String: Any]
|
|
let initOptions = source["initOptions"] as? [Any]
|
|
let uri = source["uri"] as? String
|
|
let initType = source["initType"] as? Int ?? 0
|
|
let autoplay = source["autoplay"] as? Bool ?? false
|
|
let isNetwork = source["isNetwork"] as? Bool ?? false
|
|
|
|
guard let uri = uri, !uri.isEmpty else { return }
|
|
|
|
if initType == 2, let options = initOptions {
|
|
self.mediaPlayer = VLCMediaPlayer(options: options)
|
|
} else {
|
|
self.mediaPlayer = VLCMediaPlayer()
|
|
}
|
|
|
|
self.mediaPlayer?.delegate = self
|
|
self.mediaPlayer?.drawable = self.videoView
|
|
self.mediaPlayer?.scaleFactor = 0
|
|
|
|
let media: VLCMedia
|
|
if isNetwork {
|
|
media = VLCMedia(url: URL(string: uri)!)
|
|
} else {
|
|
media = VLCMedia(path: uri)
|
|
}
|
|
|
|
media.delegate = self
|
|
if let mediaOptions = mediaOptions {
|
|
media.addOptions(mediaOptions)
|
|
}
|
|
|
|
// Parse the media asynchronously
|
|
media.parse()
|
|
self.mediaPlayer?.media = media
|
|
|
|
if autoplay {
|
|
self.play()
|
|
}
|
|
|
|
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
|
}
|
|
}
|
|
|
|
@objc func setProgressUpdateInterval(_ interval: Double) {
|
|
progressUpdateInterval = TimeInterval(interval / 1000.0)
|
|
updateProgressTimer()
|
|
}
|
|
|
|
@objc func jumpBackward(_ interval: Int) {
|
|
mediaPlayer?.jumpBackward(Int32(interval))
|
|
}
|
|
|
|
@objc func jumpForward(_ interval: Int) {
|
|
mediaPlayer?.jumpForward(Int32(interval))
|
|
}
|
|
|
|
@objc func setMuted(_ muted: Bool) {
|
|
mediaPlayer?.audio?.isMuted = muted
|
|
}
|
|
|
|
@objc func setVolume(_ volume: Int) {
|
|
mediaPlayer?.audio?.volume = Int32(volume)
|
|
}
|
|
|
|
@objc func setVideoAspectRatio(_ ratio: String) {
|
|
DispatchQueue.main.async {
|
|
ratio.withCString { cString in
|
|
self.mediaPlayer?.videoAspectRatio = UnsafeMutablePointer(mutating: cString)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func setAudioTrack(_ trackIndex: Int) {
|
|
DispatchQueue.main.async {
|
|
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
|
}
|
|
}
|
|
|
|
@objc func getAudioTracks() -> [[String: Any]]? {
|
|
guard let trackNames = mediaPlayer?.audioTrackNames,
|
|
let trackIndexes = mediaPlayer?.audioTrackIndexes
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return zip(trackNames, trackIndexes).map { name, index in
|
|
return ["name": name, "index": index]
|
|
}
|
|
}
|
|
|
|
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
|
DispatchQueue.main.async {
|
|
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
|
}
|
|
}
|
|
|
|
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
|
guard let trackNames = mediaPlayer?.videoSubTitlesNames,
|
|
let trackIndexes = mediaPlayer?.videoSubTitlesIndexes
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return zip(trackNames, trackIndexes).map { name, index in
|
|
return ["name": name, "index": index]
|
|
}
|
|
}
|
|
|
|
@objc func setSubtitleDelay(_ delay: Int) {
|
|
DispatchQueue.main.async {
|
|
self.mediaPlayer?.currentVideoSubTitleDelay = NSInteger(delay)
|
|
}
|
|
}
|
|
|
|
@objc func setAudioDelay(_ delay: Int) {
|
|
DispatchQueue.main.async {
|
|
self.mediaPlayer?.currentAudioPlaybackDelay = NSInteger(delay)
|
|
}
|
|
}
|
|
|
|
@objc func takeSnapshot(_ path: String, width: Int, height: Int) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
self.mediaPlayer?.saveVideoSnapshot(
|
|
at: path, withWidth: Int32(width), andHeight: Int32(height))
|
|
}
|
|
}
|
|
|
|
@objc func setVideoCropGeometry(_ geometry: String?) {
|
|
DispatchQueue.main.async {
|
|
if let geometry = geometry, !geometry.isEmpty {
|
|
self.currentGeometryCString = geometry.cString(using: .utf8)
|
|
self.currentGeometryCString?.withUnsafeMutableBufferPointer { buffer in
|
|
self.mediaPlayer?.videoCropGeometry = buffer.baseAddress
|
|
}
|
|
} else {
|
|
self.currentGeometryCString = nil
|
|
self.mediaPlayer?.videoCropGeometry = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func getVideoCropGeometry() -> String? {
|
|
guard let cString = mediaPlayer?.videoCropGeometry else {
|
|
return nil
|
|
}
|
|
return String(cString: cString)
|
|
}
|
|
|
|
@objc func setRate(_ rate: Float) {
|
|
DispatchQueue.main.async {
|
|
self.mediaPlayer?.rate = rate
|
|
}
|
|
}
|
|
|
|
@objc func nextChapter() {
|
|
DispatchQueue.main.async {
|
|
self.mediaPlayer?.nextChapter()
|
|
}
|
|
}
|
|
|
|
@objc func previousChapter() {
|
|
DispatchQueue.main.async {
|
|
self.mediaPlayer?.previousChapter()
|
|
}
|
|
}
|
|
|
|
@objc func getChapters() -> [[String: Any]]? {
|
|
guard let currentTitleIndex = mediaPlayer?.currentTitleIndex,
|
|
let chapters = mediaPlayer?.chapterDescriptions(ofTitle: currentTitleIndex)
|
|
as? [[String: Any]]
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return chapters.compactMap { chapter in
|
|
guard let name = chapter[VLCChapterDescriptionName] as? String,
|
|
let timeOffset = chapter[VLCChapterDescriptionTimeOffset] as? NSNumber,
|
|
let duration = chapter[VLCChapterDescriptionDuration] as? NSNumber
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return [
|
|
"name": name,
|
|
"timeOffset": timeOffset.doubleValue,
|
|
"duration": duration.doubleValue,
|
|
]
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
private func updateProgressTimer() {
|
|
progressUpdateTimer?.invalidate()
|
|
progressUpdateTimer = Timer.scheduledTimer(
|
|
withTimeInterval: progressUpdateInterval, repeats: true
|
|
) { [weak self] _ in
|
|
self?.sendProgressUpdate()
|
|
}
|
|
}
|
|
|
|
private func sendProgressUpdate() {
|
|
DispatchQueue.main.async {
|
|
guard let player = self.mediaPlayer else { return }
|
|
let currentTime = player.time.intValue
|
|
let duration = player.media?.length.intValue ?? 0
|
|
let progress: [String: Any] = [
|
|
"currentTime": currentTime,
|
|
"duration": duration,
|
|
]
|
|
self.onVideoProgress?(progress)
|
|
}
|
|
}
|
|
|
|
@objc private func applicationWillResignActive() {
|
|
if !isPaused {
|
|
pause()
|
|
}
|
|
}
|
|
|
|
@objc private func applicationWillEnterForeground() {
|
|
if !isPaused {
|
|
play()
|
|
}
|
|
}
|
|
|
|
private func release() {
|
|
DispatchQueue.main.async {
|
|
self.mediaPlayer?.stop()
|
|
self.mediaPlayer = nil
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
}
|
|
|
|
// MARK: - Expo Events
|
|
|
|
@objc var onProgress: RCTDirectEventBlock?
|
|
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
|
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
|
@objc var onVideoStateChange: RCTDirectEventBlock?
|
|
@objc var onVideoProgress: RCTDirectEventBlock?
|
|
|
|
// MARK: - Deinitialization
|
|
|
|
deinit {
|
|
stateUpdateTimer?.invalidate()
|
|
release()
|
|
}
|
|
}
|
|
|
|
extension VlcPlayerView: VLCMediaPlayerDelegate {
|
|
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
|
guard let player = self.mediaPlayer else { return }
|
|
|
|
let currentState = player.state
|
|
|
|
// If the state hasn't changed, don't do anything
|
|
guard currentState != lastReportedState else { return }
|
|
|
|
// Cancel any pending state update
|
|
stateUpdateTimer?.invalidate()
|
|
|
|
// Schedule a new state update
|
|
stateUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) {
|
|
[weak self] _ in
|
|
self?.reportStateChange(currentState)
|
|
}
|
|
}
|
|
|
|
private func reportStateChange(_ state: VLCMediaPlayerState) {
|
|
DispatchQueue.main.async {
|
|
guard let player = self.mediaPlayer else { return }
|
|
|
|
var stateInfo: [String: Any] = [
|
|
"target": self.reactTag ?? NSNull(),
|
|
"currentTime": player.time.intValue,
|
|
"duration": player.media?.length.intValue ?? 0,
|
|
]
|
|
|
|
switch state {
|
|
case .opening:
|
|
stateInfo["type"] = "Opening"
|
|
case .paused:
|
|
self.isPaused = true
|
|
stateInfo["type"] = "Paused"
|
|
case .stopped:
|
|
stateInfo["type"] = "Stopped"
|
|
case .buffering:
|
|
if player.isPlaying {
|
|
self.isPaused = false
|
|
stateInfo["type"] = "Playing"
|
|
} else {
|
|
stateInfo["type"] = "Buffering"
|
|
stateInfo["isBuffering"] = true
|
|
}
|
|
case .playing:
|
|
self.isPaused = false
|
|
stateInfo["type"] = "Playing"
|
|
case .esAdded:
|
|
stateInfo["type"] = "ESAdded"
|
|
case .ended:
|
|
print("VLCMediaPlayerStateEnded")
|
|
stateInfo["type"] = "Ended"
|
|
case .error:
|
|
stateInfo["type"] = "Error"
|
|
self.release()
|
|
@unknown default:
|
|
stateInfo["type"] = "Unknown"
|
|
}
|
|
|
|
self.lastReportedState = state
|
|
self.onVideoStateChange?(stateInfo)
|
|
}
|
|
}
|
|
|
|
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
updateVideoProgress()
|
|
}
|
|
|
|
private func updateVideoProgress() {
|
|
DispatchQueue.main.async {
|
|
guard let player = self.mediaPlayer else { return }
|
|
|
|
let currentTime = player.time.intValue
|
|
let duration = player.media?.length.intValue ?? 0
|
|
|
|
if currentTime >= 0 && currentTime < duration {
|
|
self.onVideoProgress?([
|
|
"target": self.reactTag ?? NSNull(),
|
|
"currentTime": currentTime,
|
|
"duration": duration,
|
|
])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension VlcPlayerView: VLCMediaDelegate {
|
|
func mediaMetaDataDidChange(_ aMedia: VLCMedia) {
|
|
// Implement if needed
|
|
}
|
|
|
|
func mediaDidFinishParsing(_ aMedia: VLCMedia) {
|
|
DispatchQueue.main.async {
|
|
let duration = aMedia.length.intValue
|
|
self.onVideoStateChange?(["type": "MediaParsed", "duration": duration])
|
|
}
|
|
}
|
|
}
|
|
|
|
extension VLCMediaPlayerState {
|
|
var description: String {
|
|
switch self {
|
|
case .opening: return "Opening"
|
|
case .buffering: return "Buffering"
|
|
case .playing: return "Playing"
|
|
case .paused: return "Paused"
|
|
case .stopped: return "Stopped"
|
|
case .ended: return "Ended"
|
|
case .error: return "Error"
|
|
case .esAdded: return "ESAdded"
|
|
@unknown default: return "Unknown"
|
|
}
|
|
}
|
|
}
|