Unblocks TestFlight 0.5.36 after media.parse/.buffering API removals and optional EventPlayItem Binding inference.
826 lines
29 KiB
Swift
826 lines
29 KiB
Swift
import AVKit
|
|
import Combine
|
|
import SwiftUI
|
|
import UIKit
|
|
import VLCKit
|
|
|
|
struct PlayerTrack: Identifiable, Hashable {
|
|
/// VLCKit4 trackId, of `"__off__"` voor ondertitels uit.
|
|
let id: String
|
|
let name: String
|
|
}
|
|
|
|
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: String = PlayerTrack.offId
|
|
@Published var currentAudioId: String = ""
|
|
@Published var pipPossible = false
|
|
@Published var pipActive = false
|
|
|
|
static let offSubtitleId = PlayerTrack.offId
|
|
|
|
private let library: VLCLibrary
|
|
let mediaPlayer: VLCMediaPlayer
|
|
private let drawableHost = VLCPipDrawableHost()
|
|
/// Plain UIView-target voor live/ClearKey (PiP sample-buffer-vout breekt DASH/proxy).
|
|
private weak var plainVideoView: UIView?
|
|
private var lengthMs: Int32 = 0
|
|
private var tick: AnyCancellable?
|
|
private var clearKeyProxyActive = false
|
|
private var liveMode = false
|
|
private var livePlayURL: URL?
|
|
private var livePlayFormat: String?
|
|
private var liveStartedAt: Date?
|
|
private var liveRetryCount = 0
|
|
private var everPlayed = false
|
|
private var pipWindow: VLCPictureInPictureWindowControlling?
|
|
private var sceneObserver: NSObjectProtocol?
|
|
/// Voorkomt herhaald `drawable=` bij SwiftUI-ticks (audio-zonder-beeld).
|
|
private weak var attachedView: UIView?
|
|
/// VOD: plain UIView voor FreeType-subs; PiP-drawable alleen bij PiP-start.
|
|
private var usePipDrawable = false
|
|
private var stopping = false
|
|
|
|
override init() {
|
|
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
|
|
library = VLCLibrary.shared()
|
|
mediaPlayer = VLCMediaPlayer(library: library)
|
|
super.init()
|
|
mediaPlayer.delegate = self
|
|
drawableHost.controller = self
|
|
configureAudioSession()
|
|
observeSceneForAutoPip()
|
|
}
|
|
|
|
deinit {
|
|
if let sceneObserver {
|
|
NotificationCenter.default.removeObserver(sceneObserver)
|
|
}
|
|
mediaPlayer.drawable = nil
|
|
mediaPlayer.stop()
|
|
if clearKeyProxyActive {
|
|
ClearKeyDashProxy.shared.stop()
|
|
}
|
|
}
|
|
|
|
func attach(to view: UIView) {
|
|
if attachedView === view { return }
|
|
attachedView = view
|
|
plainVideoView = view
|
|
view.backgroundColor = .black
|
|
applyDrawable()
|
|
}
|
|
|
|
/// Live/ClearKey of VOD-inline → plain UIView (FreeType). PiP → sample-buffer drawable.
|
|
private func applyDrawable() {
|
|
guard let view = plainVideoView else { return }
|
|
let wantPip = usePipDrawable && !liveMode && !clearKeyProxyActive
|
|
if wantPip {
|
|
drawableHost.attach(to: view)
|
|
if mediaPlayer.drawable as AnyObject? !== drawableHost {
|
|
mediaPlayer.drawable = drawableHost
|
|
}
|
|
} else {
|
|
drawableHost.detachVideo()
|
|
if mediaPlayer.drawable as AnyObject? !== view {
|
|
mediaPlayer.drawable = view
|
|
}
|
|
if liveMode || clearKeyProxyActive {
|
|
pipPossible = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func startPictureInPicture() {
|
|
preparePipDrawableThen {
|
|
self.pipWindow?.startPictureInPicture()
|
|
}
|
|
}
|
|
|
|
func stopPictureInPicture() {
|
|
pipWindow?.stopPictureInPicture()
|
|
}
|
|
|
|
func togglePictureInPicture() {
|
|
if pipActive { stopPictureInPicture() }
|
|
else { startPictureInPicture() }
|
|
}
|
|
|
|
private func preparePipDrawableThen(_ work: @escaping () -> Void) {
|
|
if usePipDrawable, pipWindow != nil {
|
|
work()
|
|
return
|
|
}
|
|
usePipDrawable = true
|
|
applyDrawable()
|
|
// pictureInPictureReady komt async; kort wachten op pipWindow.
|
|
var attempts = 0
|
|
func tryStart() {
|
|
attempts += 1
|
|
if self.pipWindow != nil || attempts >= 12 {
|
|
work()
|
|
} else {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: tryStart)
|
|
}
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: tryStart)
|
|
}
|
|
|
|
private func restorePlainDrawableAfterPip() {
|
|
guard !liveMode, !clearKeyProxyActive, !stopping else { return }
|
|
usePipDrawable = false
|
|
applyDrawable()
|
|
applySubtitleStyle()
|
|
}
|
|
|
|
func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) {
|
|
hasError = false
|
|
errorMessage = nil
|
|
stateText = "Verbinden…"
|
|
subtitleTracks = []
|
|
audioTracks = []
|
|
liveMode = false
|
|
clearKeyProxyActive = false
|
|
usePipDrawable = false
|
|
applyDrawable()
|
|
// PiP-knop tonen; drawable wisselt pas bij PiP-start (FreeType blijft werken).
|
|
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
|
|
guard let media = VLCMedia(url: url) else {
|
|
hasError = true
|
|
errorMessage = "Kon media niet openen."
|
|
stateText = "Fout"
|
|
return
|
|
}
|
|
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")
|
|
mediaPlayer.media = media
|
|
// VLCKit 4: parse is on VLCMediaParser, not VLCMedia
|
|
_ = VLCMediaParser.shared().queue(
|
|
media,
|
|
options: VLCMediaParsingOptions(rawValue: 0x02 | 0x08) // network + fetchLocal
|
|
)
|
|
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] = []
|
|
) async {
|
|
hasError = false
|
|
errorMessage = nil
|
|
stateText = "Live laden…"
|
|
liveRetryCount = 0
|
|
everPlayed = false
|
|
liveStartedAt = Date()
|
|
liveMode = true
|
|
usePipDrawable = false
|
|
|
|
var playURL = url
|
|
var playFormat = format
|
|
clearKeyProxyActive = false
|
|
|
|
if !clearKeys.isEmpty {
|
|
do {
|
|
stateText = "ClearKey laden…"
|
|
// Async: blokkeer de main thread niet (watchdog-crash).
|
|
// HLS (ESPN) én DASH (Prime) via dezelfde proxy.
|
|
playURL = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
|
|
playFormat = ClearKeyDashProxy.isHlsURL(url) ? "hls" : "dash"
|
|
clearKeyProxyActive = true
|
|
} catch {
|
|
hasError = true
|
|
errorMessage = dutchClearKeyError(error)
|
|
stateText = "Fout"
|
|
return
|
|
}
|
|
}
|
|
|
|
applyDrawable()
|
|
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?) {
|
|
guard let media = VLCMedia(url: url) else {
|
|
hasError = true
|
|
errorMessage = "Kon live-stream niet openen."
|
|
stateText = "Fout"
|
|
return
|
|
}
|
|
media.delegate = self
|
|
Self.applyLiveOptions(to: media, url: url, format: format)
|
|
mediaPlayer.media = media
|
|
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 "Stream-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)"
|
|
}
|
|
|
|
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=1800")
|
|
media.addOption(":http-reconnect")
|
|
media.addOption(":live-caching=1500")
|
|
media.addOption(":avcodec-hw=any")
|
|
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
|
|
// VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module).
|
|
media.addOption(":adaptive-logic=highest")
|
|
media.addOption(":adaptive-use-access")
|
|
|
|
let fmt = (format ?? "").lowercased()
|
|
let path = url.absoluteString.lowercased()
|
|
let isHls = fmt == "hls" || path.contains(".m3u8") || path.contains("m3u8")
|
|
if isHls {
|
|
// HLS mag nog steeds via adaptive; geen aparte demux forceren.
|
|
}
|
|
}
|
|
|
|
func togglePlay() {
|
|
if mediaPlayer.isPlaying { mediaPlayer.pause() }
|
|
else { mediaPlayer.play() }
|
|
invalidatePipPlaybackState()
|
|
}
|
|
|
|
func seek(toMs: Int64) {
|
|
mediaPlayer.time = VLCTime(number: NSNumber(value: toMs))
|
|
invalidatePipPlaybackState()
|
|
}
|
|
|
|
func seek(ratio: Float) {
|
|
mediaPlayer.position = Double(ratio)
|
|
invalidatePipPlaybackState()
|
|
}
|
|
|
|
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: String) {
|
|
if id == PlayerTrack.offId {
|
|
mediaPlayer.deselectAllTextTracks()
|
|
currentSubtitleId = PlayerTrack.offId
|
|
} else if let track = mediaPlayer.textTracks.first(where: { $0.trackId == id }) {
|
|
track.isSelectedExclusively = true
|
|
currentSubtitleId = id
|
|
}
|
|
invalidatePipPlaybackState()
|
|
}
|
|
|
|
func selectAudio(id: String) {
|
|
if let track = mediaPlayer.audioTracks.first(where: { $0.trackId == id }) {
|
|
track.isSelectedExclusively = true
|
|
currentAudioId = id
|
|
}
|
|
invalidatePipPlaybackState()
|
|
}
|
|
|
|
func addExternalSubtitle(url: URL) {
|
|
if pipActive {
|
|
stopPictureInPicture()
|
|
}
|
|
if usePipDrawable {
|
|
usePipDrawable = false
|
|
applyDrawable()
|
|
}
|
|
_ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
guard let self else { return }
|
|
self.refreshTracks()
|
|
self.applySubtitleStyle()
|
|
if let last = self.subtitleTracks.last(where: { $0.id != PlayerTrack.offId }) {
|
|
self.selectSubtitle(id: last.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
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() {
|
|
// VLCKit 4 kan crashen op track-API tijdens error/opening — altijd defensief.
|
|
guard mediaPlayer.media != nil else {
|
|
subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")]
|
|
audioTracks = []
|
|
return
|
|
}
|
|
let textList = mediaPlayer.textTracks
|
|
let audioList = mediaPlayer.audioTracks
|
|
|
|
var subs: [PlayerTrack] = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")]
|
|
var selectedSub = PlayerTrack.offId
|
|
for (index, track) in textList.enumerated() {
|
|
let label = Self.displayTrackLabel(
|
|
rawName: track.trackName,
|
|
fallbackIndex: index + 1,
|
|
kind: .subtitle,
|
|
languageHint: track.language,
|
|
descriptionHint: track.trackDescription
|
|
)
|
|
subs.append(PlayerTrack(id: track.trackId, name: label))
|
|
if track.isSelected { selectedSub = track.trackId }
|
|
}
|
|
subtitleTracks = subs
|
|
currentSubtitleId = selectedSub
|
|
|
|
var audios: [PlayerTrack] = []
|
|
var selectedAudio = currentAudioId
|
|
for (index, track) in audioList.enumerated() {
|
|
let label = Self.displayTrackLabel(
|
|
rawName: track.trackName,
|
|
fallbackIndex: index + 1,
|
|
kind: .audio,
|
|
languageHint: track.language,
|
|
descriptionHint: track.trackDescription
|
|
)
|
|
audios.append(PlayerTrack(id: track.trackId, name: label))
|
|
if track.isSelected { selectedAudio = track.trackId }
|
|
}
|
|
if selectedAudio.isEmpty, let first = audios.first {
|
|
selectedAudio = first.id
|
|
}
|
|
audioTracks = audios
|
|
currentAudioId = selectedAudio
|
|
}
|
|
|
|
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() {
|
|
guard !stopping else { return }
|
|
stopping = true
|
|
tick?.cancel()
|
|
tick = nil
|
|
|
|
let proxyWasActive = clearKeyProxyActive
|
|
clearKeyProxyActive = false
|
|
|
|
// Drawable los vóór stop — voorkomt crash als SwiftUI de UIView al vrijgeeft.
|
|
mediaPlayer.drawable = nil
|
|
drawableHost.detachVideo()
|
|
attachedView = nil
|
|
plainVideoView = nil
|
|
|
|
if pipActive {
|
|
pipWindow?.stopPictureInPicture()
|
|
}
|
|
pipWindow = nil
|
|
pipPossible = false
|
|
pipActive = false
|
|
usePipDrawable = false
|
|
|
|
mediaPlayer.stop()
|
|
mediaPlayer.media = nil
|
|
livePlayURL = nil
|
|
livePlayFormat = nil
|
|
liveStartedAt = nil
|
|
liveRetryCount = 0
|
|
everPlayed = false
|
|
liveMode = false
|
|
|
|
if proxyWasActive {
|
|
// Laat in-flight HTTP van VLC even afronden vóór listener-cancel.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
ClearKeyDashProxy.shared.stop()
|
|
}
|
|
}
|
|
stopping = false
|
|
}
|
|
|
|
// MARK: - VLCMediaPlayerDelegate (VLCKit 4)
|
|
|
|
func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.isPlaying = self.mediaPlayer.isPlaying
|
|
if self.pipPossible {
|
|
self.invalidatePipPlaybackState()
|
|
}
|
|
switch newState {
|
|
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, .stopping:
|
|
if self.clearKeyProxyActive && !self.everPlayed {
|
|
self.stateText = "Verbinden…"
|
|
self.retryLiveIfNeeded()
|
|
} else if !self.hasError {
|
|
self.stateText = "Gestopt"
|
|
}
|
|
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"
|
|
}
|
|
@unknown default: break
|
|
}
|
|
}
|
|
}
|
|
|
|
func mediaPlayerTrackAdded(_ trackId: String, with type: VLCMedia.TrackType) {
|
|
DispatchQueue.main.async { [weak self] in self?.refreshTracks() }
|
|
}
|
|
|
|
func mediaPlayerTrackUpdated(_ trackId: String, with type: VLCMedia.TrackType) {
|
|
DispatchQueue.main.async { [weak self] in self?.refreshTracks() }
|
|
}
|
|
|
|
func mediaPlayerLengthChanged(_ length: Int64) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.invalidatePipPlaybackState()
|
|
}
|
|
}
|
|
|
|
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 = Float(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)
|
|
}
|
|
|
|
// MARK: - PiP / audio
|
|
|
|
fileprivate func onPictureInPictureReady(_ controller: VLCPictureInPictureWindowControlling) {
|
|
pipWindow = controller
|
|
pipPossible = true
|
|
controller.stateChangeEventHandler = { [weak self] isStarted in
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
self.pipActive = isStarted
|
|
if !isStarted {
|
|
self.restorePlainDrawableAfterPip()
|
|
}
|
|
}
|
|
}
|
|
// Automatisch PiP bij app → achtergrond (Netflix-gedrag).
|
|
if let obj = controller as? NSObject {
|
|
for key in ["avPipController", "avPictureInPictureController", "_avPipController"] {
|
|
if let av = obj.value(forKey: key) as? AVPictureInPictureController {
|
|
if #available(iOS 14.2, *) {
|
|
av.canStartPictureInPictureAutomaticallyFromInline = true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate func invalidatePipPlaybackState() {
|
|
pipWindow?.invalidatePlaybackState()
|
|
}
|
|
|
|
private func configureAudioSession() {
|
|
let session = AVAudioSession.sharedInstance()
|
|
do {
|
|
try session.setCategory(.playback, mode: .moviePlayback, options: [])
|
|
try session.setActive(true)
|
|
} catch {
|
|
// Speelt vaak nog steeds; PiP vereist wel background audio mode in Info.plist.
|
|
}
|
|
}
|
|
|
|
private func observeSceneForAutoPip() {
|
|
sceneObserver = NotificationCenter.default.addObserver(
|
|
forName: UIApplication.didEnterBackgroundNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self, self.isPlaying, !self.pipActive else { return }
|
|
guard !self.liveMode, !self.clearKeyProxyActive else { return }
|
|
self.startPictureInPicture()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension PlayerTrack {
|
|
static let offId = "__off__"
|
|
}
|
|
|
|
// MARK: - VLC drawable + Apple PiP bridge
|
|
|
|
final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawable, VLCPictureInPictureMediaControlling {
|
|
weak var controller: VLCPlayerController?
|
|
private weak var container: UIView?
|
|
private var videoSubview: UIView?
|
|
|
|
func attach(to view: UIView) {
|
|
container = view
|
|
view.backgroundColor = .black
|
|
if let videoSubview, videoSubview.superview !== view {
|
|
videoSubview.removeFromSuperview()
|
|
view.addSubview(videoSubview)
|
|
videoSubview.frame = view.bounds
|
|
videoSubview.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
}
|
|
}
|
|
|
|
func detachVideo() {
|
|
videoSubview?.removeFromSuperview()
|
|
// Houd referentie; VLC kan hem opnieuw addSubview'en.
|
|
}
|
|
|
|
// VLCDrawable (VLCKit 4: methods, not properties)
|
|
func addSubview(_ view: UIView) {
|
|
videoSubview = view
|
|
guard let container else { return }
|
|
container.subviews.filter { $0 !== view }.forEach { $0.removeFromSuperview() }
|
|
if view.superview !== container {
|
|
container.addSubview(view)
|
|
}
|
|
view.frame = container.bounds
|
|
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
}
|
|
|
|
func bounds() -> CGRect {
|
|
container?.bounds ?? .zero
|
|
}
|
|
|
|
// VLCPictureInPictureDrawable
|
|
func mediaController() -> (any VLCPictureInPictureMediaControlling)? { self }
|
|
|
|
func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)? {
|
|
{ [weak self] pip in
|
|
guard let pip else { return }
|
|
self?.controller?.onPictureInPictureReady(pip)
|
|
}
|
|
}
|
|
|
|
// VLCPictureInPictureMediaControlling
|
|
func play() {
|
|
controller?.mediaPlayer.play()
|
|
}
|
|
|
|
func pause() {
|
|
controller?.mediaPlayer.pause()
|
|
}
|
|
|
|
func seek(by offset: Int64, completion: @escaping () -> Void) {
|
|
guard let player = controller?.mediaPlayer else {
|
|
completion()
|
|
return
|
|
}
|
|
let ok = player.jump(withOffset: Int32(clamping: offset), completion: completion)
|
|
if !ok { completion() }
|
|
}
|
|
|
|
func mediaLength() -> Int64 {
|
|
Int64(controller?.mediaPlayer.media?.length.intValue ?? 0)
|
|
}
|
|
|
|
func mediaTime() -> Int64 {
|
|
Int64(controller?.mediaPlayer.time.intValue ?? 0)
|
|
}
|
|
|
|
func isMediaSeekable() -> Bool {
|
|
controller?.mediaPlayer.isSeekable ?? false
|
|
}
|
|
|
|
func isMediaPlaying() -> Bool {
|
|
controller?.mediaPlayer.isPlaying ?? false
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout).
|
|
controller.attach(to: uiView)
|
|
}
|
|
}
|