Preserve BaseURL/Location, warm up MPD, clear sinf for VLC, and retry early stops so custom live DRM plays instead of Gestopt.
553 lines
21 KiB
Swift
553 lines
21 KiB
Swift
import Combine
|
|
import MobileVLCKit
|
|
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 var clearKeyProxyActive = false
|
|
private var livePlayURL: URL?
|
|
private var livePlayFormat: String?
|
|
private var liveStartedAt: Date?
|
|
private var liveRetryCount = 0
|
|
private var everPlayed = false
|
|
|
|
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")
|
|
// 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
|
|
// Async parse zodat tracksInformation language/title beschikbaar wordt.
|
|
// MobileVLCKit (deze binary) exposeert parseWithOptions: niet netjes naar Swift.
|
|
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,
|
|
format: String? = nil,
|
|
clearKeys: [ClearKeyEntry] = []
|
|
) {
|
|
hasError = false
|
|
errorMessage = nil
|
|
stateText = "Live laden…"
|
|
liveRetryCount = 0
|
|
everPlayed = false
|
|
liveStartedAt = Date()
|
|
|
|
var playURL = url
|
|
var playFormat = format
|
|
clearKeyProxyActive = false
|
|
|
|
if !clearKeys.isEmpty {
|
|
do {
|
|
// MobileVLCKit kan CENC/ClearKey niet zelf; lokale proxy decrypt segmenten.
|
|
// start() wacht tot listener + MPD-warmup klaar zijn.
|
|
playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
|
|
playFormat = "dash"
|
|
clearKeyProxyActive = true
|
|
stateText = "ClearKey laden…"
|
|
} catch {
|
|
hasError = true
|
|
errorMessage = dutchClearKeyError(error)
|
|
stateText = "Fout"
|
|
return
|
|
}
|
|
}
|
|
|
|
livePlayURL = playURL
|
|
livePlayFormat = playFormat
|
|
startLiveMedia(url: playURL, format: playFormat)
|
|
|
|
if let fallback, clearKeys.isEmpty {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
|
|
guard let self, !self.isPlaying || self.hasError else { return }
|
|
self.startLiveMedia(url: fallback, format: nil)
|
|
}
|
|
}
|
|
startTicker()
|
|
}
|
|
|
|
private func startLiveMedia(url: URL, format: String?) {
|
|
let media = VLCMedia(url: url)
|
|
media.delegate = self
|
|
Self.applyLiveOptions(to: media, url: url, format: format)
|
|
mediaPlayer.media = media
|
|
// Geen parse() op live/DASH — blokkeert/stopt soms meteen op dynamic MPD.
|
|
mediaPlayer.play()
|
|
applySubtitleStyle()
|
|
}
|
|
|
|
private func dutchClearKeyError(_ error: Error) -> String {
|
|
let msg = error.localizedDescription
|
|
if msg.localizedCaseInsensitiveContains("upstream") {
|
|
return "Stream-server gaf een fout (\(msg)). Probeer opnieuw."
|
|
}
|
|
if msg.localizedCaseInsensitiveContains("leeg") || msg.localizedCaseInsensitiveContains("ongeldig") {
|
|
return "DASH-manifest kon niet worden geladen. Controleer de zender-URL."
|
|
}
|
|
if msg.localizedCaseInsensitiveContains("proxy") || msg.localizedCaseInsensitiveContains("bind") {
|
|
return "Lokale ClearKey-proxy startte niet. Herstart de app en probeer opnieuw."
|
|
}
|
|
if msg.localizedCaseInsensitiveContains("sleutel") {
|
|
return "Geen geldige ClearKey-sleutels voor deze zender."
|
|
}
|
|
return "ClearKey-stream mislukt: \(msg)"
|
|
}
|
|
|
|
/// Na vroege Gestopt/error: één keer opnieuw afspelen (proxy blijft actief).
|
|
private func retryLiveIfNeeded() {
|
|
guard clearKeyProxyActive, !everPlayed, liveRetryCount < 2 else { return }
|
|
guard let url = livePlayURL else { return }
|
|
let elapsed = Date().timeIntervalSince(liveStartedAt ?? Date())
|
|
guard elapsed < 12 else { return }
|
|
liveRetryCount += 1
|
|
stateText = "Opnieuw verbinden…"
|
|
hasError = false
|
|
errorMessage = nil
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
|
|
guard let self, self.clearKeyProxyActive else { return }
|
|
self.startLiveMedia(url: url, format: self.livePlayFormat)
|
|
}
|
|
}
|
|
|
|
private static func applyLiveOptions(
|
|
to media: VLCMedia,
|
|
url: URL,
|
|
format: String?
|
|
) {
|
|
media.addOption(":network-caching=1200")
|
|
media.addOption(":http-reconnect")
|
|
media.addOption(":live-caching=1000")
|
|
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
|
|
|
|
let fmt = (format ?? "").lowercased()
|
|
let path = url.absoluteString.lowercased()
|
|
let isDash = fmt == "dash" || path.contains(".mpd")
|
|
let isHls = fmt == "hls" || path.contains(".m3u8") || path.contains("m3u8")
|
|
|
|
if isDash {
|
|
media.addOption(":demux=dash")
|
|
media.addOption(":adaptive-logic=highest")
|
|
media.addOption(":adaptive-use-access")
|
|
} else if isHls {
|
|
media.addOption(":demux=hls")
|
|
}
|
|
}
|
|
|
|
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() {
|
|
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
|
|
livePlayURL = nil
|
|
livePlayFormat = nil
|
|
liveStartedAt = nil
|
|
liveRetryCount = 0
|
|
everPlayed = false
|
|
if clearKeyProxyActive {
|
|
ClearKeyDashProxy.shared.stop()
|
|
clearKeyProxyActive = false
|
|
}
|
|
}
|
|
|
|
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.errorMessage = nil
|
|
self.everPlayed = true
|
|
self.refreshTracks()
|
|
self.applySubtitleStyle()
|
|
case .paused: self.stateText = "Pauze"
|
|
case .stopped:
|
|
// Vroege "Gestopt" bij ClearKey is vaak tijdelijk (init/segment race) — niet meteen opgeven.
|
|
if self.clearKeyProxyActive && !self.everPlayed {
|
|
self.stateText = "Verbinden…"
|
|
self.retryLiveIfNeeded()
|
|
} else {
|
|
self.stateText = "Gestopt"
|
|
}
|
|
case .ended: self.stateText = "Einde"
|
|
case .error:
|
|
if self.clearKeyProxyActive && !self.everPlayed && self.liveRetryCount < 2 {
|
|
self.stateText = "Opnieuw…"
|
|
self.retryLiveIfNeeded()
|
|
} else {
|
|
self.hasError = true
|
|
self.errorMessage = self.clearKeyProxyActive
|
|
? "Kon deze ClearKey-livezender niet afspelen. Controleer netwerk of probeer opnieuw."
|
|
: "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)
|
|
}
|
|
}
|