From a38824db2738e8357cfac54b0255c4e94473ad3a Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sat, 19 Sep 2026 15:26:27 +0200 Subject: [PATCH] Add iOS Picture in Picture via VLCKit 4 upgrade. --- apps/ios/README.md | 24 +- apps/ios/VMC.xcodeproj/project.pbxproj | 20 +- apps/ios/VMC/Data/Models.swift | 4 - apps/ios/VMC/Info.plist | 4 + apps/ios/VMC/Playback/PlayerScreen.swift | 14 + .../VMC/Playback/VLCPlayerController.swift | 366 ++++++++++++------ apps/ios/VMC/VMCApp.swift | 7 + apps/ios/scripts/fetch-vlckit.sh | 62 +-- apps/ios/scripts/link-vlckit.sh | 107 +---- 9 files changed, 337 insertions(+), 271 deletions(-) diff --git a/apps/ios/README.md b/apps/ios/README.md index 783a661..0d6e300 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -1,4 +1,4 @@ -# VMC iOS (SwiftUI + MobileVLCKit) +# VMC iOS (SwiftUI + VLCKit 4) Native iPhone/iPad app voor Vonas Media Cluster. @@ -9,17 +9,25 @@ cd ~/Code/mediacluster/apps/ios open VMC.xcodeproj ``` -Eerste keer (of na schone sync) VLCKit zetten: +Eerste keer (of na schone sync) **VLCKit 4** zetten (PiP): ```bash -bash scripts/fetch-vlckit.sh # download ~250MB (eenmalig) -bash scripts/link-vlckit.sh # kopieert naar Vendor/ + linkt in Xcode +bash scripts/fetch-vlckit.sh # download ~500MB VLCKit 4.0.0a19 +bash scripts/link-vlckit.sh # → Vendor/VLCKit.xcframework ``` +> **Let op:** MobileVLCKit 3.x heeft **geen** Apple Picture in Picture. VMC iOS 0.5.3+ vereist VLCKit 4. + In Xcode: -1. **Signing & Capabilities** → jouw Team +1. **Signing & Capabilities** → Team + Background Modes → *Audio, AirPlay, and Picture in Picture* 2. iPhone selecteren → ▶ Run +## Picture in Picture + +- Systeem-PiP via VLCKit (`VLCPictureInPictureDrawable`) +- PiP-knop in de speler + automatisch bij app naar achtergrond tijdens afspelen +- ClearKey/DASH blijft via lokale `ClearKeyDashProxy` → plain DASH in VLC + --- ## Apple TV (tvOS) @@ -35,17 +43,17 @@ open VMCTV.xcodeproj - Bundle ID: `nl.vonas.mediacluster.tvos` - Team: `X25NYX3K49` -- Playback: **TVVLCKit** (zelfde VLC-engine als iPhone) — MKV werkt +- Playback: **TVVLCKit** (VLC 3.x engine) — PiP is iPhone/iPad-only - Details: `VMCTV/README.md` ## Let op -`Vendor/MobileVLCKit.xcframework` en `Vendor/TVVLCKit.xcframework` staan niet in git (te groot). Altijd op de Mac via de scripts installeren. +`Vendor/VLCKit.xcframework` en `Vendor/TVVLCKit.xcframework` staan niet in git (te groot). Altijd op de Mac via de scripts installeren. ## Live TV / Events / ClearKey (0.5.2+) - `streamId` is een **String** (`"123"` of `"custom:uuid"`); path-segmenten worden URL-geëncodeerd (inclusief `:`). - Play-response parseert `format`, `drm` (ClearKey kids/keys) en `watchSessionId` voor live-lijsten én events. - Heartbeat: `POST /api/v1/client/watching/heartbeat` ~elke 25s bij custom live + events. -- **ClearKey/DASH:** lokale in-app proxy (`ClearKeyDashProxy`) haalt MPD/segmenten op (dynamic live: BaseURL/Location/query/Range), decrypt CENC/CBCS (`sinf`→`free`), en speelt plain DASH via MobileVLCKit. Xtream HLS/TS speelt direct zonder proxy. +- **ClearKey/DASH:** lokale in-app proxy (`ClearKeyDashProxy`) haalt MPD/segmenten op (dynamic live: BaseURL/Location/query/Range), decrypt CENC/CBCS (`sinf`→`free`), en speelt plain DASH via VLCKit. Xtream HLS/TS speelt direct zonder proxy. - Events-tab verschijnt als `/me` → `events.enabled` true is. diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index 29cef11..caa9d6d 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -36,8 +36,8 @@ B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */; }; B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */; }; 468E05CB91FC4A1997A2F386 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6FA95D6A03BF48EA97553486 /* Assets.xcassets */; }; - DC8D7BF328134E1E8D7E3D76 /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */; }; - E9BB0F1F350A442FB6440AE5 /* MobileVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + DC8D7BF328134E1E8D7E3D76 /* VLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* VLCKit.xcframework */; }; + E9BB0F1F350A442FB6440AE5 /* VLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* VLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -72,7 +72,7 @@ D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = ""; }; 6FA95D6A03BF48EA97553486 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A22966A1CCBE44999F75FCA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Vendor/MobileVLCKit.xcframework; sourceTree = ""; }; + BE257D7C209E4803903206A8 /* VLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = VLCKit.xcframework; path = Vendor/VLCKit.xcframework; sourceTree = ""; }; E3BC8A7BDE75425E84A174DE /* VMC.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VMC.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -83,7 +83,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - E9BB0F1F350A442FB6440AE5 /* MobileVLCKit.xcframework in Embed Frameworks */, + E9BB0F1F350A442FB6440AE5 /* VLCKit.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -95,7 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DC8D7BF328134E1E8D7E3D76 /* MobileVLCKit.xcframework in Frameworks */, + DC8D7BF328134E1E8D7E3D76 /* VLCKit.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -106,7 +106,7 @@ isa = PBXGroup; children = ( 7200A95B2D6F482FA1FDA1A1 /* VMC */, - BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */, + BE257D7C209E4803903206A8 /* VLCKit.xcframework */, AA621A418F3940A281D959B4 /* Products */, ); sourceTree = ""; @@ -337,7 +337,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -352,7 +352,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.2; + MARKETING_VERSION = 0.5.3; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -370,7 +370,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -385,7 +385,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.2; + MARKETING_VERSION = 0.5.3; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apps/ios/VMC/Data/Models.swift b/apps/ios/VMC/Data/Models.swift index bf4c44a..14a7a2b 100644 --- a/apps/ios/VMC/Data/Models.swift +++ b/apps/ios/VMC/Data/Models.swift @@ -548,7 +548,3 @@ struct ApiErrorBody: Decodable { let error: ErrorObj? } -struct PlayerTrack: Identifiable, Hashable { - let id: Int32 - let name: String -} diff --git a/apps/ios/VMC/Info.plist b/apps/ios/VMC/Info.plist index f5970c1..258d939 100644 --- a/apps/ios/VMC/Info.plist +++ b/apps/ios/VMC/Info.plist @@ -51,6 +51,10 @@ NSPhotoLibraryUsageDescription VMC gebruikt je fotobibliotheek om een profielfoto te kiezen. + UIBackgroundModes + + audio + UILaunchScreen UIColorName diff --git a/apps/ios/VMC/Playback/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift index d11f553..8b5d2cd 100644 --- a/apps/ios/VMC/Playback/PlayerScreen.swift +++ b/apps/ios/VMC/Playback/PlayerScreen.swift @@ -159,6 +159,20 @@ struct PlayerScreen: View { .foregroundStyle(.white.opacity(0.7)) } Spacer() + if player.pipPossible { + Button { + player.togglePictureInPicture() + scheduleHide() + } label: { + Image(systemName: player.pipActive + ? "pip.exit" + : "pip.enter") + .font(.title3) + .foregroundStyle(.white) + .padding(12) + .background(.black.opacity(0.4), in: Circle()) + } + } if !request.isLive { Button { showTracks = true diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift index d06265c..c20c01e 100644 --- a/apps/ios/VMC/Playback/VLCPlayerController.swift +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -1,7 +1,14 @@ +import AVKit import Combine -import MobileVLCKit 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 @@ -13,10 +20,16 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg @Published var errorMessage: String? @Published var subtitleTracks: [PlayerTrack] = [] @Published var audioTracks: [PlayerTrack] = [] - @Published var currentSubtitleId: Int32 = -1 - @Published var currentAudioId: Int32 = -1 + @Published var currentSubtitleId: String = PlayerTrack.offId + @Published var currentAudioId: String = "" + @Published var pipPossible = false + @Published var pipActive = false - let mediaPlayer = VLCMediaPlayer() + static let offSubtitleId = PlayerTrack.offId + + private let library: VLCLibrary + let mediaPlayer: VLCMediaPlayer + private let drawableHost = VLCPipDrawableHost() private var lengthMs: Int32 = 0 private var tick: AnyCancellable? private var clearKeyProxyActive = false @@ -25,14 +38,43 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private var liveStartedAt: Date? private var liveRetryCount = 0 private var everPlayed = false + private var pipWindow: VLCPictureInPictureWindowControlling? + private var sceneObserver: NSObjectProtocol? override init() { + // Eigen library-instance zodat opties/PiP-vout stabiel blijven. + library = VLCLibrary(options: []) + mediaPlayer = VLCMediaPlayer(library: library) super.init() mediaPlayer.delegate = self + drawableHost.controller = self + mediaPlayer.drawable = drawableHost + configureAudioSession() + observeSceneForAutoPip() + } + + deinit { + if let sceneObserver { + NotificationCenter.default.removeObserver(sceneObserver) + } } func attach(to view: UIView) { - mediaPlayer.drawable = view + drawableHost.attach(to: view) + mediaPlayer.drawable = drawableHost + } + + func startPictureInPicture() { + pipWindow?.startPictureInPicture() + } + + func stopPictureInPicture() { + pipWindow?.stopPictureInPicture() + } + + func togglePictureInPicture() { + if pipActive { stopPictureInPicture() } + else { startPictureInPicture() } } func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) { @@ -48,13 +90,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg 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() + _ = media.parseWithOptions(VLCMediaParsingOptions(rawValue: 0x02 | 0x08)) // network + fetchLocal mediaPlayer.play() applySubtitleStyle() if startMs > 2000 { @@ -93,8 +132,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg if !clearKeys.isEmpty { do { - // MobileVLCKit kan CENC/ClearKey niet zelf; lokale proxy decrypt segmenten. - // start() wacht tot listener + MPD-warmup klaar zijn. + // VLCKit speelt geen CENC/ClearKey zelf; lokale proxy decrypt segmenten. playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys) playFormat = "dash" clearKeyProxyActive = true @@ -125,7 +163,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg 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() } @@ -147,7 +184,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg 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 } @@ -190,14 +226,17 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg 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 = ratio + mediaPlayer.position = Double(ratio) + invalidatePipPlaybackState() } func currentPositionMs() -> Int64 { Int64(mediaPlayer.time.intValue) } @@ -207,25 +246,33 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg return Int64(max(v, 0)) } - func selectSubtitle(id: Int32) { - mediaPlayer.currentVideoSubTitleIndex = id - currentSubtitleId = id + 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: Int32) { - mediaPlayer.currentAudioTrackIndex = id - currentAudioId = id + func selectAudio(id: String) { + if let track = mediaPlayer.audioTracks.first(where: { $0.trackId == id }) { + track.isSelectedExclusively = true + currentAudioId = id + } + invalidatePipPlaybackState() } func addExternalSubtitle(url: URL) { - mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true) + _ = 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() { @@ -237,101 +284,39 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } 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: PlayerTrack.offId, name: "Uit")] + for (index, track) in mediaPlayer.textTracks.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 { currentSubtitleId = track.trackId } } - - 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)) - } + if !mediaPlayer.textTracks.contains(where: \.isSelected) { + currentSubtitleId = PlayerTrack.offId } 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)) - } + for (index, track) in mediaPlayer.audioTracks.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 { currentAudioId = track.trackId } + } + if currentAudioId.isEmpty, let first = audios.first { + currentAudioId = first.id } 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 } @@ -447,6 +432,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg func stop() { tick?.cancel() tick = nil + stopPictureInPicture() mediaPlayer.stop() mediaPlayer.media = nil livePlayURL = nil @@ -460,11 +446,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } } - func mediaPlayerStateChanged(_ aNotification: Notification) { + // MARK: - VLCMediaPlayerDelegate (VLCKit 4) + + func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.isPlaying = self.mediaPlayer.isPlaying - switch self.mediaPlayer.state { + self.invalidatePipPlaybackState() + switch newState { case .buffering: self.stateText = "Bufferen…" case .opening: self.stateText = "Openen…" case .playing: @@ -475,15 +464,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg self.refreshTracks() self.applySubtitleStyle() case .paused: self.stateText = "Pauze" - case .stopped: - // Vroege "Gestopt" bij ClearKey is vaak tijdelijk (init/segment race) — niet meteen opgeven. + case .stopped, .stopping: 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…" @@ -495,13 +482,25 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg : "Kon dit bestand niet afspelen" self.stateText = "Fout" } - case .esAdded: - self.refreshTracks() @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() @@ -521,7 +520,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg let t = mediaPlayer.time.intValue let l = mediaPlayer.media?.length.intValue ?? 0 if l > 0 { lengthMs = l } - position = mediaPlayer.position + position = Float(mediaPlayer.position) timeText = Self.format(ms: t) lengthText = Self.format(ms: lengthMs) isPlaying = mediaPlayer.isPlaying @@ -535,6 +534,137 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg 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 { + self?.pipActive = isStarted + } + } + // 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, self.pipPossible 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] + } + } + + // VLCDrawable + 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] + } + + var bounds: CGRect { + container?.bounds ?? .zero + } + + // VLCPictureInPictureDrawable + var mediaController: VLCPictureInPictureMediaControlling { self } + + func pictureInPictureReady() -> (VLCPictureInPictureWindowControlling) -> Void { + { [weak self] pip in + self?.controller?.onPictureInPictureReady(pip) + } + } + + // VLCPictureInPictureMediaControlling + func play() { + controller?.mediaPlayer.play() + } + + func pause() { + controller?.mediaPlayer.pause() + } + + @objc(seekBy:completion:) + func seekBy(_ offset: Int64, completion: @escaping () -> Void) { + guard let player = controller?.mediaPlayer else { + completion() + return + } + let ok = player.jump(withOffset: Int(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 { diff --git a/apps/ios/VMC/VMCApp.swift b/apps/ios/VMC/VMCApp.swift index fa24adf..68f2692 100644 --- a/apps/ios/VMC/VMCApp.swift +++ b/apps/ios/VMC/VMCApp.swift @@ -1,9 +1,16 @@ +import AVFoundation import SwiftUI @main struct VMCApp: App { @StateObject private var appModel = AppModel() + init() { + let session = AVAudioSession.sharedInstance() + try? session.setCategory(.playback, mode: .moviePlayback) + try? session.setActive(true) + } + var body: some Scene { WindowGroup { RootView() diff --git a/apps/ios/scripts/fetch-vlckit.sh b/apps/ios/scripts/fetch-vlckit.sh index d413cc2..1b7a135 100644 --- a/apps/ios/scripts/fetch-vlckit.sh +++ b/apps/ios/scripts/fetch-vlckit.sh @@ -1,49 +1,21 @@ #!/bin/zsh set -euo pipefail -cd /tmp -echo "Listing MobileVLCKit packages..." -curl -fsSL https://download.videolan.org/pub/cocoapods/prod/ -o /tmp/vlc-prod.html -python3 - <<'PY' -from pathlib import Path -import re -html = Path('/tmp/vlc-prod.html').read_text(errors='ignore') -names = sorted(set(re.findall(r'href="(MobileVLCKit-3\.[^"]+)"', html))) -for n in names[-25:]: - print(n) -PY +# VLCKit 4.x (unified) — required for Apple Picture in Picture. +# MobileVLCKit 3.7.x has NO PiP API. -# Prefer newest 3.5/3.6 if present else latest 3.x -python3 - <<'PY' -from pathlib import Path -import re -html = Path('/tmp/vlc-prod.html').read_text(errors='ignore') -names = sorted(set(re.findall(r'href="(MobileVLCKit-3\.[^"]+)"', html))) -pick = None -for n in reversed(names): - if n.startswith('MobileVLCKit-3.5') or n.startswith('MobileVLCKit-3.6'): - pick = n - break -if not pick and names: - pick = names[-1] -print('PICK=' + (pick or '')) -Path('/tmp/vlc-pick.txt').write_text(pick or '') -PY +URL='https://download.videolan.org/pub/cocoapods/unstable/VLCKit-4.0.0a19-d7597c1706-85a537d69.tar.xz' +OUT=/tmp/VLCKit-4.0.0a19.tar.xz +UNPACK=/tmp/vlckit4unpack -PICK=$(cat /tmp/vlc-pick.txt) -if [ -z "$PICK" ]; then - echo "No package found" - exit 1 -fi -echo "Downloading $PICK ..." -curl -fL --progress-bar "https://download.videolan.org/pub/cocoapods/prod/$PICK" -o "/tmp/$PICK" -mkdir -p /tmp/vlcunpack -rm -rf /tmp/vlcunpack/* -case "$PICK" in - *.zip) unzip -q "/tmp/$PICK" -d /tmp/vlcunpack ;; - *.tar.xz|*.txz) tar -xJf "/tmp/$PICK" -C /tmp/vlcunpack ;; - *.tar.bz2) tar -xjf "/tmp/$PICK" -C /tmp/vlcunpack ;; - *) tar -xf "/tmp/$PICK" -C /tmp/vlcunpack ;; -esac -echo "Unpacked contents:" -find /tmp/vlcunpack -maxdepth 3 -type d | head -40 -find /tmp/vlcunpack -name '*.framework' -o -name '*.xcframework' | head -20 +echo "Downloading VLCKit 4.0.0a19 (~500MB)…" +curl -fL --progress-bar -o "$OUT" "$URL" + +rm -rf "$UNPACK" +mkdir -p "$UNPACK" +echo "Unpacking…" +tar -xJf "$OUT" -C "$UNPACK" + +echo "Unpacked:" +find "$UNPACK" -name 'VLCKit.xcframework' -type d | head -5 +test -d "$UNPACK/VLCKit-binary/VLCKit.xcframework" +echo "OK — run: bash scripts/link-vlckit.sh" diff --git a/apps/ios/scripts/link-vlckit.sh b/apps/ios/scripts/link-vlckit.sh index 7033b65..090a14a 100644 --- a/apps/ios/scripts/link-vlckit.sh +++ b/apps/ios/scripts/link-vlckit.sh @@ -1,94 +1,29 @@ #!/bin/bash set -euo pipefail -IOS="$HOME/Code/mediacluster/apps/ios" +IOS="${HOME}/Code/mediacluster/apps/ios" mkdir -p "$IOS/Vendor" -rm -rf "$IOS/Vendor/MobileVLCKit.xcframework" -cp -R /tmp/vlcunpack/MobileVLCKit-binary/MobileVLCKit.xcframework "$IOS/Vendor/" -ls "$IOS/Vendor/MobileVLCKit.xcframework" -python3 <<'PY' -from pathlib import Path -import uuid, re +SRC="/tmp/vlckit4unpack/VLCKit-binary/VLCKit.xcframework" +if [ ! -d "$SRC" ]; then + echo "Missing $SRC — run scripts/fetch-vlckit.sh first" + exit 1 +fi -pbx_path = Path.home() / "Code/mediacluster/apps/ios/VMC.xcodeproj/project.pbxproj" -text = pbx_path.read_text() -if "MobileVLCKit.xcframework" in text: - print("Already linked") - raise SystemExit(0) +# Backup oude MobileVLCKit indien aanwezig +if [ -d "$IOS/Vendor/MobileVLCKit.xcframework" ]; then + mv "$IOS/Vendor/MobileVLCKit.xcframework" \ + "$IOS/Vendor/MobileVLCKit.xcframework.bak.$(date +%Y%m%d%H%M)" || true +fi -def nid(): - return uuid.uuid4().hex[:24].upper() +rm -rf "$IOS/Vendor/VLCKit.xcframework" +cp -R "$SRC" "$IOS/Vendor/VLCKit.xcframework" -fw_ref, fw_build, embed_build, embed_phase = nid(), nid(), nid(), nid() +HDR="$IOS/Vendor/VLCKit.xcframework/ios-arm64/VLCKit.framework/Headers/VLCDrawable.h" +if ! grep -q 'VLCPictureInPictureDrawable' "$HDR"; then + echo "ERROR: PiP headers missing in $HDR" + exit 1 +fi -text = text.replace( - "/* End PBXFileReference section */", - f'\t\t{fw_ref} /* MobileVLCKit.xcframework */ = {{isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Vendor/MobileVLCKit.xcframework; sourceTree = ""; }};\n/* End PBXFileReference section */', -) - -text = text.replace( - "/* End PBXBuildFile section */", - ( - f"\t\t{fw_build} /* MobileVLCKit.xcframework in Frameworks */ = {{isa = PBXBuildFile; fileRef = {fw_ref} /* MobileVLCKit.xcframework */; }};\n" - f"\t\t{embed_build} /* MobileVLCKit.xcframework in Embed Frameworks */ = {{isa = PBXBuildFile; fileRef = {fw_ref} /* MobileVLCKit.xcframework */; settings = {{ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }}; }};\n" - "/* End PBXBuildFile section */" - ), -) - -text = re.sub( - r"(isa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = \()\n(\t\t\t\);)", - rf"\1\n\t\t\t\t{fw_build} /* MobileVLCKit.xcframework in Frameworks */,\n\2", - text, - count=1, -) - -if "PBXCopyFilesBuildPhase" not in text: - block = f"""/* Begin PBXCopyFilesBuildPhase section */ -\t\t{embed_phase} /* Embed Frameworks */ = {{ -\t\t\tisa = PBXCopyFilesBuildPhase; -\t\t\tbuildActionMask = 2147483647; -\t\t\tdstPath = ""; -\t\t\tdstSubfolderSpec = 10; -\t\t\tfiles = ( -\t\t\t\t{embed_build} /* MobileVLCKit.xcframework in Embed Frameworks */, -\t\t\t); -\t\t\tname = "Embed Frameworks"; -\t\t\trunOnlyForDeploymentPostprocessing = 0; -\t\t}}; -/* End PBXCopyFilesBuildPhase section */ - -""" - text = text.replace("/* Begin PBXFrameworksBuildPhase section */", block + "/* Begin PBXFrameworksBuildPhase section */") - -text = re.sub( - r"(buildPhases = \(\n\t\t\t\t[A-F0-9]+ /\* Sources \*/,\n\t\t\t\t[A-F0-9]+ /\* Frameworks \*/,)", - rf"\1\n\t\t\t\t{embed_phase} /* Embed Frameworks */,", - text, - count=1, -) - -# Insert into root group children (before Products) -text = re.sub( - r"(children = \(\n\t\t\t\t[A-F0-9]+ /\* VMC \*/,)\n(\t\t\t\t[A-F0-9]+ /\* Products \*/,)", - rf"\1\n\t\t\t\t{fw_ref} /* MobileVLCKit.xcframework */,\n\2", - text, - count=1, -) - -# Target build settings -text = text.replace( - "MARKETING_VERSION = 0.2.0;\n", - ( - "MARKETING_VERSION = 0.2.0;\n" - "\t\t\t\tFRAMEWORK_SEARCH_PATHS = (\n" - '\t\t\t\t\t"$(inherited)",\n' - '\t\t\t\t\t"$(PROJECT_DIR)/Vendor",\n' - "\t\t\t\t);\n" - "\t\t\t\tENABLE_BITCODE = NO;\n" - '\t\t\t\tOTHER_LDFLAGS = ("$(inherited)", "-ObjC");\n' - ), -) - -pbx_path.write_text(text) -print("Patched OK") -PY +echo "Linked Vendor/VLCKit.xcframework" +du -sh "$IOS/Vendor/VLCKit.xcframework" +grep -n 'VLCPictureInPictureDrawable' "$HDR" | head -3