SwiftUI app against master API: link, profiles, browse, live TV, favorites sync, and TestFlight-ready assets. Co-authored-by: Cursor <cursoragent@cursor.com>
261 lines
8.9 KiB
Swift
261 lines
8.9 KiB
Swift
import Combine
|
|
import MobileVLCKit
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate {
|
|
@Published var isPlaying = false
|
|
@Published var position: Float = 0
|
|
@Published var timeText = "0:00"
|
|
@Published var lengthText = "0:00"
|
|
@Published var stateText = "Laden…"
|
|
@Published var hasError = false
|
|
@Published var errorMessage: String?
|
|
@Published var subtitleTracks: [PlayerTrack] = []
|
|
@Published var audioTracks: [PlayerTrack] = []
|
|
@Published var currentSubtitleId: Int32 = -1
|
|
@Published var currentAudioId: Int32 = -1
|
|
|
|
let mediaPlayer = VLCMediaPlayer()
|
|
private var lengthMs: Int32 = 0
|
|
private var tick: AnyCancellable?
|
|
|
|
override init() {
|
|
super.init()
|
|
mediaPlayer.delegate = self
|
|
}
|
|
|
|
func attach(to view: UIView) {
|
|
mediaPlayer.drawable = view
|
|
}
|
|
|
|
func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) {
|
|
hasError = false
|
|
errorMessage = nil
|
|
stateText = "Verbinden…"
|
|
subtitleTracks = []
|
|
audioTracks = []
|
|
let media = VLCMedia(url: url)
|
|
media.addOption(":network-caching=2500")
|
|
media.addOption(":http-reconnect")
|
|
media.addOption(":avcodec-hw=any")
|
|
media.addOption(":sout-mux-caching=1500")
|
|
media.addOption(":sub-track=0")
|
|
// VLC: hoger = kleiner (hoogte ≈ scherm/N). Default ~16 is te groot op iPhone.
|
|
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
|
media.addOption(":freetype-bold=1")
|
|
mediaPlayer.media = media
|
|
mediaPlayer.play()
|
|
applySubtitleStyle()
|
|
if startMs > 2000 {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
|
|
self?.seek(toMs: startMs)
|
|
}
|
|
}
|
|
if let subtitleFileURL {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
|
|
self?.addExternalSubtitle(url: subtitleFileURL)
|
|
}
|
|
}
|
|
startTicker()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
|
self?.refreshTracks()
|
|
self?.applySubtitleStyle()
|
|
}
|
|
}
|
|
|
|
func playLive(url: URL, fallback: URL? = nil) {
|
|
hasError = false
|
|
errorMessage = nil
|
|
stateText = "Live laden…"
|
|
let media = VLCMedia(url: url)
|
|
media.addOption(":network-caching=1200")
|
|
media.addOption(":http-reconnect")
|
|
media.addOption(":live-caching=1000")
|
|
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
|
mediaPlayer.media = media
|
|
mediaPlayer.play()
|
|
applySubtitleStyle()
|
|
if let fallback {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
|
|
guard let self, !self.isPlaying || self.hasError else { return }
|
|
let fb = VLCMedia(url: fallback)
|
|
fb.addOption(":network-caching=1200")
|
|
fb.addOption(":http-reconnect")
|
|
fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
|
self.mediaPlayer.media = fb
|
|
self.mediaPlayer.play()
|
|
self.applySubtitleStyle()
|
|
}
|
|
}
|
|
startTicker()
|
|
}
|
|
|
|
func togglePlay() {
|
|
if mediaPlayer.isPlaying { mediaPlayer.pause() }
|
|
else { mediaPlayer.play() }
|
|
}
|
|
|
|
func seek(toMs: Int64) {
|
|
mediaPlayer.time = VLCTime(number: NSNumber(value: toMs))
|
|
}
|
|
|
|
func seek(ratio: Float) {
|
|
mediaPlayer.position = ratio
|
|
}
|
|
|
|
func currentPositionMs() -> Int64 { Int64(mediaPlayer.time.intValue) }
|
|
|
|
func durationMs() -> Int64 {
|
|
let v = mediaPlayer.media?.length.intValue ?? lengthMs
|
|
return Int64(max(v, 0))
|
|
}
|
|
|
|
func selectSubtitle(id: Int32) {
|
|
mediaPlayer.currentVideoSubTitleIndex = id
|
|
currentSubtitleId = id
|
|
}
|
|
|
|
func selectAudio(id: Int32) {
|
|
mediaPlayer.currentAudioTrackIndex = id
|
|
currentAudioId = id
|
|
}
|
|
|
|
func addExternalSubtitle(url: URL) {
|
|
mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
|
|
self?.refreshTracks()
|
|
self?.applySubtitleStyle()
|
|
}
|
|
}
|
|
|
|
/// VLC relative fontsize: groter getal = kleinere tekst (~1/N van schermhoogte).
|
|
private static let subtitleRelFontSize = 32
|
|
|
|
private func applySubtitleStyle() {
|
|
let size = NSNumber(value: Self.subtitleRelFontSize)
|
|
let sel = NSSelectorFromString("setTextRendererFontSize:")
|
|
if mediaPlayer.responds(to: sel) {
|
|
mediaPlayer.perform(sel, with: size)
|
|
}
|
|
}
|
|
|
|
func refreshTracks() {
|
|
var subs: [PlayerTrack] = [PlayerTrack(id: -1, name: "Uit")]
|
|
if let names = mediaPlayer.videoSubTitlesNames as? [Any],
|
|
let indexes = mediaPlayer.videoSubTitlesIndexes as? [Any] {
|
|
for (i, raw) in indexes.enumerated() {
|
|
let idx = (raw as? NSNumber)?.int32Value ?? Int32(truncating: raw as? NSNumber ?? -1)
|
|
if idx < 0 { continue }
|
|
let label: String
|
|
if i < names.count, let n = names[i] as? String, !n.isEmpty {
|
|
label = n
|
|
} else {
|
|
label = "Ondertitel \(subs.count)"
|
|
}
|
|
// Skip junk mime-like labels
|
|
if label.uppercased().contains("TEXT/") || label.uppercased().hasPrefix("HTTP") {
|
|
subs.append(PlayerTrack(id: idx, name: "Ondertitel \(subs.count)"))
|
|
} else {
|
|
subs.append(PlayerTrack(id: idx, name: label))
|
|
}
|
|
}
|
|
}
|
|
subtitleTracks = subs
|
|
currentSubtitleId = mediaPlayer.currentVideoSubTitleIndex
|
|
|
|
var audios: [PlayerTrack] = []
|
|
if let names = mediaPlayer.audioTrackNames as? [Any],
|
|
let indexes = mediaPlayer.audioTrackIndexes as? [Any] {
|
|
for (i, raw) in indexes.enumerated() {
|
|
let idx = (raw as? NSNumber)?.int32Value ?? -1
|
|
if idx < 0 { continue }
|
|
let label: String
|
|
if i < names.count, let n = names[i] as? String, !n.isEmpty {
|
|
label = n
|
|
} else {
|
|
label = "Audio \(audios.count + 1)"
|
|
}
|
|
audios.append(PlayerTrack(id: idx, name: label))
|
|
}
|
|
}
|
|
audioTracks = audios
|
|
currentAudioId = mediaPlayer.currentAudioTrackIndex
|
|
}
|
|
|
|
func stop() {
|
|
tick?.cancel()
|
|
tick = nil
|
|
mediaPlayer.stop()
|
|
mediaPlayer.media = nil
|
|
}
|
|
|
|
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.isPlaying = self.mediaPlayer.isPlaying
|
|
switch self.mediaPlayer.state {
|
|
case .buffering: self.stateText = "Bufferen…"
|
|
case .opening: self.stateText = "Openen…"
|
|
case .playing:
|
|
self.stateText = "Afspelen"
|
|
self.hasError = false
|
|
self.refreshTracks()
|
|
self.applySubtitleStyle()
|
|
case .paused: self.stateText = "Pauze"
|
|
case .stopped: self.stateText = "Gestopt"
|
|
case .ended: self.stateText = "Einde"
|
|
case .error:
|
|
self.hasError = true
|
|
self.errorMessage = "Kon dit bestand niet afspelen"
|
|
self.stateText = "Fout"
|
|
case .esAdded:
|
|
self.refreshTracks()
|
|
@unknown default: break
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startTicker() {
|
|
tick?.cancel()
|
|
tick = Timer.publish(every: 0.4, on: .main, in: .common)
|
|
.autoconnect()
|
|
.sink { [weak self] _ in
|
|
self?.refreshTime()
|
|
}
|
|
}
|
|
|
|
private func refreshTime() {
|
|
let t = mediaPlayer.time.intValue
|
|
let l = mediaPlayer.media?.length.intValue ?? 0
|
|
if l > 0 { lengthMs = l }
|
|
position = mediaPlayer.position
|
|
timeText = Self.format(ms: t)
|
|
lengthText = Self.format(ms: lengthMs)
|
|
isPlaying = mediaPlayer.isPlaying
|
|
}
|
|
|
|
private static func format(ms: Int32) -> String {
|
|
let total = max(Int(ms) / 1000, 0)
|
|
let h = total / 3600
|
|
let m = (total % 3600) / 60
|
|
let s = total % 60
|
|
if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
|
|
return String(format: "%d:%02d", m, s)
|
|
}
|
|
}
|
|
|
|
struct VLCVideoView: UIViewRepresentable {
|
|
let controller: VLCPlayerController
|
|
|
|
func makeUIView(context: Context) -> UIView {
|
|
let view = UIView()
|
|
view.backgroundColor = .black
|
|
controller.attach(to: view)
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ uiView: UIView, context: Context) {
|
|
controller.attach(to: uiView)
|
|
}
|
|
}
|