stremio/apps/ios/VMCTV/Playback/VLCPlayerController.swift

451 lines
17 KiB
Swift

import Combine
import TVVLCKit
import SwiftUI
import UIKit
final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate, VLCMediaDelegate {
@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?
private static func applyCdnHeaders(to media: VLCMedia, url: URL) {
if url.host?.localizedCaseInsensitiveContains("viaplay") == true {
media.addOption(":http-user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36")
media.addOption(":http-referrer=https://viaplay.com/")
}
}
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.delegate = self
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")
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
media.addOption(":freetype-bold=1")
Self.applyCdnHeaders(to: media, url: url)
mediaPlayer.media = media
media.parse()
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.delegate = self
media.addOption(":network-caching=1200")
media.addOption(":http-reconnect")
media.addOption(":live-caching=1000")
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
Self.applyCdnHeaders(to: media, url: url)
mediaPlayer.media = media
media.parse()
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.delegate = self
fb.addOption(":network-caching=1200")
fb.addOption(":http-reconnect")
fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
Self.applyCdnHeaders(to: fb, url: fallback)
self.mediaPlayer.media = fb
fb.parse()
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(byMs delta: Int64) {
seek(toMs: max(0, currentPositionMs() + delta))
}
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: hoger getal = kleinere tekst. TV iets groter dan iPhone (32).
private static let subtitleRelFontSize = 22
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() {
let meta = trackMetaById()
var textOrderHints: [String?] = []
var audioOrderHints: [String?] = []
for info in (mediaPlayer.media?.tracksInformation as? [Any]) ?? [] {
guard let dict = info as? [AnyHashable: Any] else { continue }
let type = ((dict[VLCMediaTracksInformationType] as? String)
?? (dict["type"] as? String)
?? "").lowercased()
let lang = (dict[VLCMediaTracksInformationLanguage] as? String)
?? (dict["language"] as? String)
let desc = (dict[VLCMediaTracksInformationDescription] as? String)
?? (dict["description"] as? String)
let hint = lang ?? desc
if type.contains("text") {
textOrderHints.append(hint)
} else if type.contains("audio") {
audioOrderHints.append(hint)
}
}
var subs: [PlayerTrack] = [PlayerTrack(id: -1, name: "Uit")]
var textOrd = 0
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 rawName: String = {
if i < names.count, let n = names[i] as? String, !n.isEmpty { return n }
return ""
}()
let fromId = meta[idx]
let fromOrder = textOrd < textOrderHints.count ? textOrderHints[textOrd] : nil
textOrd += 1
let label = Self.displayTrackLabel(
rawName: rawName,
fallbackIndex: subs.count,
kind: .subtitle,
languageHint: fromId?.language ?? fromOrder,
descriptionHint: fromId?.description
)
subs.append(PlayerTrack(id: idx, name: label))
}
}
subtitleTracks = subs
currentSubtitleId = mediaPlayer.currentVideoSubTitleIndex
var audios: [PlayerTrack] = []
var audioOrd = 0
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 rawName: String = {
if i < names.count, let n = names[i] as? String, !n.isEmpty { return n }
return ""
}()
let fromId = meta[idx]
let fromOrder = audioOrd < audioOrderHints.count ? audioOrderHints[audioOrd] : nil
audioOrd += 1
let label = Self.displayTrackLabel(
rawName: rawName,
fallbackIndex: audios.count + 1,
kind: .audio,
languageHint: fromId?.language ?? fromOrder,
descriptionHint: fromId?.description
)
audios.append(PlayerTrack(id: idx, name: label))
}
}
audioTracks = audios
currentAudioId = mediaPlayer.currentAudioTrackIndex
}
private struct TrackMeta {
var language: String?
var description: String?
}
private func trackMetaById() -> [Int32: TrackMeta] {
guard let media = mediaPlayer.media else { return [:] }
var out: [Int32: TrackMeta] = [:]
for info in (media.tracksInformation as? [Any]) ?? [] {
guard let dict = info as? [AnyHashable: Any] else { continue }
let tid = (dict[VLCMediaTracksInformationId] as? NSNumber)?.int32Value
?? (dict["id"] as? NSNumber)?.int32Value
guard let tid else { continue }
let lang = (dict[VLCMediaTracksInformationLanguage] as? String)
?? (dict["language"] as? String)
let desc = (dict[VLCMediaTracksInformationDescription] as? String)
?? (dict["description"] as? String)
out[tid] = TrackMeta(language: lang, description: desc)
}
return out
}
private enum TrackKind { case subtitle, audio }
private static func displayTrackLabel(
rawName: String,
fallbackIndex: Int,
kind: TrackKind,
languageHint: String?,
descriptionHint: String?
) -> String {
let prettyLang =
prettyLanguage(languageHint)
?? prettyLanguage(extractLanguageToken(from: descriptionHint))
?? prettyLanguage(extractLanguageToken(from: rawName))
if isGenericTrackLabel(rawName)
|| rawName.uppercased().contains("TEXT/")
|| rawName.uppercased().hasPrefix("HTTP")
{
if let prettyLang {
if let descriptionHint,
!descriptionHint.isEmpty,
!isGenericTrackLabel(descriptionHint),
!descriptionHint.localizedCaseInsensitiveContains(prettyLang)
{
return "\(prettyLang) · \(descriptionHint)"
}
return prettyLang
}
return kind == .subtitle ? "Ondertitel \(fallbackIndex)" : "Audio \(fallbackIndex)"
}
if let prettyLang, !rawName.localizedCaseInsensitiveContains(prettyLang) {
return "\(prettyLang) · \(rawName)"
}
return rawName
}
private static func isGenericTrackLabel(_ raw: String?) -> Bool {
let s = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if s.isEmpty { return true }
let lower = s.lowercased()
if lower == "und" || lower == "unknown" || lower == "null" || lower == "sub" || lower == "subtitle" {
return true
}
if lower.range(of: #"^track\s*\d+$"#, options: .regularExpression) != nil { return true }
if lower.range(of: #"^subtitle\s*\d+$"#, options: .regularExpression) != nil { return true }
if lower.range(of: #"^ondertitel\s*\d+$"#, options: .regularExpression) != nil { return true }
if lower.range(of: #"^audio\s*\d+$"#, options: .regularExpression) != nil { return true }
return false
}
private static func extractLanguageToken(from raw: String?) -> String? {
guard let raw, !raw.isEmpty else { return nil }
let lower = raw.lowercased()
let known = [
"nederlands", "dutch", "nl", "dut", "nld",
"engels", "english", "en", "eng",
"duits", "german", "de", "ger", "deu",
"frans", "french", "fr", "fre", "fra",
"spaans", "spanish", "es", "spa",
"italiaans", "italian", "it", "ita",
"portugees", "portuguese", "pt", "por",
"pools", "polish", "pl", "pol",
"russisch", "russian", "ru", "rus",
"japans", "japanese", "ja", "jpn",
"koreaans", "korean", "ko", "kor",
"chinees", "chinese", "zh", "chi", "zho",
]
let tokens = lower.split(whereSeparator: { !$0.isLetter }).map(String.init)
for token in known where tokens.contains(token) || lower == token {
return token
}
if let match = lower.range(of: #"\b[a-z]{2,3}\b"#, options: .regularExpression) {
return String(lower[match])
}
return nil
}
private static func prettyLanguage(_ code: String?) -> String? {
let c = (code ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !c.isEmpty, c != "und", c != "null", c != "unknown" else { return nil }
switch c {
case "nl", "dut", "nld", "dutch", "nederlands": return "Nederlands"
case "en", "eng", "english", "engels": return "Engels"
case "de", "ger", "deu", "german", "duits": return "Duits"
case "fr", "fre", "fra", "french", "frans": return "Frans"
case "es", "spa", "spanish", "spaans": return "Spaans"
case "it", "ita", "italian", "italiaans": return "Italiaans"
case "pt", "por", "portuguese", "portugees": return "Portugees"
case "pl", "pol", "polish", "pools": return "Pools"
case "ru", "rus", "russian", "russisch": return "Russisch"
case "ja", "jpn", "japanese", "japans": return "Japans"
case "ko", "kor", "korean", "koreaans": return "Koreaans"
case "zh", "chi", "zho", "cmn", "chinese", "chinees": return "Chinees"
case "ar", "ara", "arabic", "arabisch": return "Arabisch"
case "tr", "tur", "turkish", "turks": return "Turks"
case "sv", "swe", "swedish", "zweeds": return "Zweeds"
case "no", "nor", "nb", "nn", "norwegian", "noors": return "Noors"
case "da", "dan", "danish", "deens": return "Deens"
case "fi", "fin", "finnish", "fins": return "Fins"
default:
if c.count <= 3 {
let name = Locale(identifier: "nl_NL").localizedString(forLanguageCode: c)
if let name, !name.isEmpty, name.lowercased() != c { return name.capitalized }
return c.uppercased()
}
return c.capitalized
}
}
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
}
}
}
func mediaDidFinishParsing(_ aMedia: VLCMedia) {
DispatchQueue.main.async { [weak self] in
self?.refreshTracks()
}
}
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)
}
}