Add iOS Picture in Picture via VLCKit 4 upgrade.

This commit is contained in:
Jos Vooges | STH 2026-09-19 15:26:27 +02:00
parent 796edf36db
commit a38824db27
9 changed files with 337 additions and 271 deletions

View file

@ -1,4 +1,4 @@
# VMC iOS (SwiftUI + MobileVLCKit) # VMC iOS (SwiftUI + VLCKit 4)
Native iPhone/iPad app voor Vonas Media Cluster. Native iPhone/iPad app voor Vonas Media Cluster.
@ -9,17 +9,25 @@ cd ~/Code/mediacluster/apps/ios
open VMC.xcodeproj open VMC.xcodeproj
``` ```
Eerste keer (of na schone sync) VLCKit zetten: Eerste keer (of na schone sync) **VLCKit 4** zetten (PiP):
```bash ```bash
bash scripts/fetch-vlckit.sh # download ~250MB (eenmalig) bash scripts/fetch-vlckit.sh # download ~500MB VLCKit 4.0.0a19
bash scripts/link-vlckit.sh # kopieert naar Vendor/ + linkt in Xcode 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: In Xcode:
1. **Signing & Capabilities** → jouw Team 1. **Signing & Capabilities** → Team + Background Modes → *Audio, AirPlay, and Picture in Picture*
2. iPhone selecteren → ▶ Run 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) ## Apple TV (tvOS)
@ -35,17 +43,17 @@ open VMCTV.xcodeproj
- Bundle ID: `nl.vonas.mediacluster.tvos` - Bundle ID: `nl.vonas.mediacluster.tvos`
- Team: `X25NYX3K49` - 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` - Details: `VMCTV/README.md`
## Let op ## 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+) ## Live TV / Events / ClearKey (0.5.2+)
- `streamId` is een **String** (`"123"` of `"custom:uuid"`); path-segmenten worden URL-geëncodeerd (inclusief `:`). - `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. - 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. - 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. - Events-tab verschijnt als `/me` → `events.enabled` true is.

View file

@ -36,8 +36,8 @@
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */; }; B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */; };
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */; }; B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */; };
468E05CB91FC4A1997A2F386 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6FA95D6A03BF48EA97553486 /* Assets.xcassets */; }; 468E05CB91FC4A1997A2F386 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6FA95D6A03BF48EA97553486 /* Assets.xcassets */; };
DC8D7BF328134E1E8D7E3D76 /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */; }; DC8D7BF328134E1E8D7E3D76 /* VLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* VLCKit.xcframework */; };
E9BB0F1F350A442FB6440AE5 /* MobileVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; E9BB0F1F350A442FB6440AE5 /* VLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* VLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -72,7 +72,7 @@
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = "<group>"; }; D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = "<group>"; };
6FA95D6A03BF48EA97553486 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 6FA95D6A03BF48EA97553486 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A22966A1CCBE44999F75FCA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; A22966A1CCBE44999F75FCA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Vendor/MobileVLCKit.xcframework; sourceTree = "<group>"; }; BE257D7C209E4803903206A8 /* VLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = VLCKit.xcframework; path = Vendor/VLCKit.xcframework; sourceTree = "<group>"; };
E3BC8A7BDE75425E84A174DE /* VMC.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VMC.app; sourceTree = BUILT_PRODUCTS_DIR; }; E3BC8A7BDE75425E84A174DE /* VMC.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VMC.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -83,7 +83,7 @@
dstPath = ""; dstPath = "";
dstSubfolderSpec = 10; dstSubfolderSpec = 10;
files = ( files = (
E9BB0F1F350A442FB6440AE5 /* MobileVLCKit.xcframework in Embed Frameworks */, E9BB0F1F350A442FB6440AE5 /* VLCKit.xcframework in Embed Frameworks */,
); );
name = "Embed Frameworks"; name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -95,7 +95,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
DC8D7BF328134E1E8D7E3D76 /* MobileVLCKit.xcframework in Frameworks */, DC8D7BF328134E1E8D7E3D76 /* VLCKit.xcframework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -106,7 +106,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7200A95B2D6F482FA1FDA1A1 /* VMC */, 7200A95B2D6F482FA1FDA1A1 /* VMC */,
BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */, BE257D7C209E4803903206A8 /* VLCKit.xcframework */,
AA621A418F3940A281D959B4 /* Products */, AA621A418F3940A281D959B4 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@ -337,7 +337,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14; CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -352,7 +352,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.2; MARKETING_VERSION = 0.5.3;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -370,7 +370,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14; CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -385,7 +385,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.2; MARKETING_VERSION = 0.5.3;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -548,7 +548,3 @@ struct ApiErrorBody: Decodable {
let error: ErrorObj? let error: ErrorObj?
} }
struct PlayerTrack: Identifiable, Hashable {
let id: Int32
let name: String
}

View file

@ -51,6 +51,10 @@
</array> </array>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>VMC gebruikt je fotobibliotheek om een profielfoto te kiezen.</string> <string>VMC gebruikt je fotobibliotheek om een profielfoto te kiezen.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict> <dict>
<key>UIColorName</key> <key>UIColorName</key>

View file

@ -159,6 +159,20 @@ struct PlayerScreen: View {
.foregroundStyle(.white.opacity(0.7)) .foregroundStyle(.white.opacity(0.7))
} }
Spacer() 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 { if !request.isLive {
Button { Button {
showTracks = true showTracks = true

View file

@ -1,7 +1,14 @@
import AVKit
import Combine import Combine
import MobileVLCKit
import SwiftUI import SwiftUI
import UIKit 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 { final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate, VLCMediaDelegate {
@Published var isPlaying = false @Published var isPlaying = false
@ -13,10 +20,16 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var subtitleTracks: [PlayerTrack] = [] @Published var subtitleTracks: [PlayerTrack] = []
@Published var audioTracks: [PlayerTrack] = [] @Published var audioTracks: [PlayerTrack] = []
@Published var currentSubtitleId: Int32 = -1 @Published var currentSubtitleId: String = PlayerTrack.offId
@Published var currentAudioId: Int32 = -1 @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 lengthMs: Int32 = 0
private var tick: AnyCancellable? private var tick: AnyCancellable?
private var clearKeyProxyActive = false private var clearKeyProxyActive = false
@ -25,14 +38,43 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private var liveStartedAt: Date? private var liveStartedAt: Date?
private var liveRetryCount = 0 private var liveRetryCount = 0
private var everPlayed = false private var everPlayed = false
private var pipWindow: VLCPictureInPictureWindowControlling?
private var sceneObserver: NSObjectProtocol?
override init() { override init() {
// Eigen library-instance zodat opties/PiP-vout stabiel blijven.
library = VLCLibrary(options: [])
mediaPlayer = VLCMediaPlayer(library: library)
super.init() super.init()
mediaPlayer.delegate = self mediaPlayer.delegate = self
drawableHost.controller = self
mediaPlayer.drawable = drawableHost
configureAudioSession()
observeSceneForAutoPip()
}
deinit {
if let sceneObserver {
NotificationCenter.default.removeObserver(sceneObserver)
}
} }
func attach(to view: UIView) { 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) { 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(":avcodec-hw=any")
media.addOption(":sout-mux-caching=1500") media.addOption(":sout-mux-caching=1500")
media.addOption(":sub-track=0") 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-rel-fontsize=\(Self.subtitleRelFontSize)")
media.addOption(":freetype-bold=1") media.addOption(":freetype-bold=1")
mediaPlayer.media = media mediaPlayer.media = media
// Async parse zodat tracksInformation language/title beschikbaar wordt. _ = media.parseWithOptions(VLCMediaParsingOptions(rawValue: 0x02 | 0x08)) // network + fetchLocal
// MobileVLCKit (deze binary) exposeert parseWithOptions: niet netjes naar Swift.
media.parse()
mediaPlayer.play() mediaPlayer.play()
applySubtitleStyle() applySubtitleStyle()
if startMs > 2000 { if startMs > 2000 {
@ -93,8 +132,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if !clearKeys.isEmpty { if !clearKeys.isEmpty {
do { do {
// MobileVLCKit kan CENC/ClearKey niet zelf; lokale proxy decrypt segmenten. // VLCKit speelt geen CENC/ClearKey zelf; lokale proxy decrypt segmenten.
// start() wacht tot listener + MPD-warmup klaar zijn.
playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys) playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
playFormat = "dash" playFormat = "dash"
clearKeyProxyActive = true clearKeyProxyActive = true
@ -125,7 +163,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
media.delegate = self media.delegate = self
Self.applyLiveOptions(to: media, url: url, format: format) Self.applyLiveOptions(to: media, url: url, format: format)
mediaPlayer.media = media mediaPlayer.media = media
// Geen parse() op live/DASH — blokkeert/stopt soms meteen op dynamic MPD.
mediaPlayer.play() mediaPlayer.play()
applySubtitleStyle() applySubtitleStyle()
} }
@ -147,7 +184,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
return "ClearKey-stream mislukt: \(msg)" return "ClearKey-stream mislukt: \(msg)"
} }
/// Na vroege Gestopt/error: één keer opnieuw afspelen (proxy blijft actief).
private func retryLiveIfNeeded() { private func retryLiveIfNeeded() {
guard clearKeyProxyActive, !everPlayed, liveRetryCount < 2 else { return } guard clearKeyProxyActive, !everPlayed, liveRetryCount < 2 else { return }
guard let url = livePlayURL else { return } guard let url = livePlayURL else { return }
@ -190,14 +226,17 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
func togglePlay() { func togglePlay() {
if mediaPlayer.isPlaying { mediaPlayer.pause() } if mediaPlayer.isPlaying { mediaPlayer.pause() }
else { mediaPlayer.play() } else { mediaPlayer.play() }
invalidatePipPlaybackState()
} }
func seek(toMs: Int64) { func seek(toMs: Int64) {
mediaPlayer.time = VLCTime(number: NSNumber(value: toMs)) mediaPlayer.time = VLCTime(number: NSNumber(value: toMs))
invalidatePipPlaybackState()
} }
func seek(ratio: Float) { func seek(ratio: Float) {
mediaPlayer.position = ratio mediaPlayer.position = Double(ratio)
invalidatePipPlaybackState()
} }
func currentPositionMs() -> Int64 { Int64(mediaPlayer.time.intValue) } func currentPositionMs() -> Int64 { Int64(mediaPlayer.time.intValue) }
@ -207,25 +246,33 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
return Int64(max(v, 0)) return Int64(max(v, 0))
} }
func selectSubtitle(id: Int32) { func selectSubtitle(id: String) {
mediaPlayer.currentVideoSubTitleIndex = id 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 currentSubtitleId = id
} }
invalidatePipPlaybackState()
}
func selectAudio(id: Int32) { func selectAudio(id: String) {
mediaPlayer.currentAudioTrackIndex = id if let track = mediaPlayer.audioTracks.first(where: { $0.trackId == id }) {
track.isSelectedExclusively = true
currentAudioId = id currentAudioId = id
} }
invalidatePipPlaybackState()
}
func addExternalSubtitle(url: URL) { 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 DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
self?.refreshTracks() self?.refreshTracks()
self?.applySubtitleStyle() self?.applySubtitleStyle()
} }
} }
/// VLC relative fontsize: groter getal = kleinere tekst (~1/N van schermhoogte).
private static let subtitleRelFontSize = 32 private static let subtitleRelFontSize = 32
private func applySubtitleStyle() { private func applySubtitleStyle() {
@ -237,101 +284,39 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
} }
func refreshTracks() { func refreshTracks() {
let meta = trackMetaById() var subs: [PlayerTrack] = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")]
var textOrderHints: [String?] = [] for (index, track) in mediaPlayer.textTracks.enumerated() {
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( let label = Self.displayTrackLabel(
rawName: rawName, rawName: track.trackName,
fallbackIndex: subs.count, fallbackIndex: index + 1,
kind: .subtitle, kind: .subtitle,
languageHint: fromId?.language ?? fromOrder, languageHint: track.language,
descriptionHint: fromId?.description descriptionHint: track.trackDescription
) )
subs.append(PlayerTrack(id: idx, name: label)) subs.append(PlayerTrack(id: track.trackId, name: label))
if track.isSelected { currentSubtitleId = track.trackId }
} }
if !mediaPlayer.textTracks.contains(where: \.isSelected) {
currentSubtitleId = PlayerTrack.offId
} }
subtitleTracks = subs subtitleTracks = subs
currentSubtitleId = mediaPlayer.currentVideoSubTitleIndex
var audios: [PlayerTrack] = [] var audios: [PlayerTrack] = []
var audioOrd = 0 for (index, track) in mediaPlayer.audioTracks.enumerated() {
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( let label = Self.displayTrackLabel(
rawName: rawName, rawName: track.trackName,
fallbackIndex: audios.count + 1, fallbackIndex: index + 1,
kind: .audio, kind: .audio,
languageHint: fromId?.language ?? fromOrder, languageHint: track.language,
descriptionHint: fromId?.description descriptionHint: track.trackDescription
) )
audios.append(PlayerTrack(id: idx, name: label)) 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 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 enum TrackKind { case subtitle, audio }
@ -447,6 +432,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
func stop() { func stop() {
tick?.cancel() tick?.cancel()
tick = nil tick = nil
stopPictureInPicture()
mediaPlayer.stop() mediaPlayer.stop()
mediaPlayer.media = nil mediaPlayer.media = nil
livePlayURL = 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 DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
self.isPlaying = self.mediaPlayer.isPlaying self.isPlaying = self.mediaPlayer.isPlaying
switch self.mediaPlayer.state { self.invalidatePipPlaybackState()
switch newState {
case .buffering: self.stateText = "Bufferen…" case .buffering: self.stateText = "Bufferen…"
case .opening: self.stateText = "Openen…" case .opening: self.stateText = "Openen…"
case .playing: case .playing:
@ -475,15 +464,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
self.refreshTracks() self.refreshTracks()
self.applySubtitleStyle() self.applySubtitleStyle()
case .paused: self.stateText = "Pauze" case .paused: self.stateText = "Pauze"
case .stopped: case .stopped, .stopping:
// Vroege "Gestopt" bij ClearKey is vaak tijdelijk (init/segment race) — niet meteen opgeven.
if self.clearKeyProxyActive && !self.everPlayed { if self.clearKeyProxyActive && !self.everPlayed {
self.stateText = "Verbinden…" self.stateText = "Verbinden…"
self.retryLiveIfNeeded() self.retryLiveIfNeeded()
} else { } else {
self.stateText = "Gestopt" self.stateText = "Gestopt"
} }
case .ended: self.stateText = "Einde"
case .error: case .error:
if self.clearKeyProxyActive && !self.everPlayed && self.liveRetryCount < 2 { if self.clearKeyProxyActive && !self.everPlayed && self.liveRetryCount < 2 {
self.stateText = "Opnieuw…" self.stateText = "Opnieuw…"
@ -495,13 +482,25 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
: "Kon dit bestand niet afspelen" : "Kon dit bestand niet afspelen"
self.stateText = "Fout" self.stateText = "Fout"
} }
case .esAdded:
self.refreshTracks()
@unknown default: break @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) { func mediaDidFinishParsing(_ aMedia: VLCMedia) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.refreshTracks() self?.refreshTracks()
@ -521,7 +520,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
let t = mediaPlayer.time.intValue let t = mediaPlayer.time.intValue
let l = mediaPlayer.media?.length.intValue ?? 0 let l = mediaPlayer.media?.length.intValue ?? 0
if l > 0 { lengthMs = l } if l > 0 { lengthMs = l }
position = mediaPlayer.position position = Float(mediaPlayer.position)
timeText = Self.format(ms: t) timeText = Self.format(ms: t)
lengthText = Self.format(ms: lengthMs) lengthText = Self.format(ms: lengthMs)
isPlaying = mediaPlayer.isPlaying 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) } if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
return String(format: "%d:%02d", 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 { struct VLCVideoView: UIViewRepresentable {

View file

@ -1,9 +1,16 @@
import AVFoundation
import SwiftUI import SwiftUI
@main @main
struct VMCApp: App { struct VMCApp: App {
@StateObject private var appModel = AppModel() @StateObject private var appModel = AppModel()
init() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .moviePlayback)
try? session.setActive(true)
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()

View file

@ -1,49 +1,21 @@
#!/bin/zsh #!/bin/zsh
set -euo pipefail set -euo pipefail
cd /tmp # VLCKit 4.x (unified) — required for Apple Picture in Picture.
echo "Listing MobileVLCKit packages..." # MobileVLCKit 3.7.x has NO PiP API.
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
# Prefer newest 3.5/3.6 if present else latest 3.x URL='https://download.videolan.org/pub/cocoapods/unstable/VLCKit-4.0.0a19-d7597c1706-85a537d69.tar.xz'
python3 - <<'PY' OUT=/tmp/VLCKit-4.0.0a19.tar.xz
from pathlib import Path UNPACK=/tmp/vlckit4unpack
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
PICK=$(cat /tmp/vlc-pick.txt) echo "Downloading VLCKit 4.0.0a19 (~500MB)…"
if [ -z "$PICK" ]; then curl -fL --progress-bar -o "$OUT" "$URL"
echo "No package found"
exit 1 rm -rf "$UNPACK"
fi mkdir -p "$UNPACK"
echo "Downloading $PICK ..." echo "Unpacking…"
curl -fL --progress-bar "https://download.videolan.org/pub/cocoapods/prod/$PICK" -o "/tmp/$PICK" tar -xJf "$OUT" -C "$UNPACK"
mkdir -p /tmp/vlcunpack
rm -rf /tmp/vlcunpack/* echo "Unpacked:"
case "$PICK" in find "$UNPACK" -name 'VLCKit.xcframework' -type d | head -5
*.zip) unzip -q "/tmp/$PICK" -d /tmp/vlcunpack ;; test -d "$UNPACK/VLCKit-binary/VLCKit.xcframework"
*.tar.xz|*.txz) tar -xJf "/tmp/$PICK" -C /tmp/vlcunpack ;; echo "OK — run: bash scripts/link-vlckit.sh"
*.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

View file

@ -1,94 +1,29 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
IOS="$HOME/Code/mediacluster/apps/ios" IOS="${HOME}/Code/mediacluster/apps/ios"
mkdir -p "$IOS/Vendor" 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' SRC="/tmp/vlckit4unpack/VLCKit-binary/VLCKit.xcframework"
from pathlib import Path if [ ! -d "$SRC" ]; then
import uuid, re echo "Missing $SRC — run scripts/fetch-vlckit.sh first"
exit 1
fi
pbx_path = Path.home() / "Code/mediacluster/apps/ios/VMC.xcodeproj/project.pbxproj" # Backup oude MobileVLCKit indien aanwezig
text = pbx_path.read_text() if [ -d "$IOS/Vendor/MobileVLCKit.xcframework" ]; then
if "MobileVLCKit.xcframework" in text: mv "$IOS/Vendor/MobileVLCKit.xcframework" \
print("Already linked") "$IOS/Vendor/MobileVLCKit.xcframework.bak.$(date +%Y%m%d%H%M)" || true
raise SystemExit(0) fi
def nid(): rm -rf "$IOS/Vendor/VLCKit.xcframework"
return uuid.uuid4().hex[:24].upper() 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( echo "Linked Vendor/VLCKit.xcframework"
"/* End PBXFileReference section */", du -sh "$IOS/Vendor/VLCKit.xcframework"
f'\t\t{fw_ref} /* MobileVLCKit.xcframework */ = {{isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Vendor/MobileVLCKit.xcframework; sourceTree = "<group>"; }};\n/* End PBXFileReference section */', grep -n 'VLCPictureInPictureDrawable' "$HDR" | head -3
)
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