Add iOS Picture in Picture via VLCKit 4 upgrade.
This commit is contained in:
parent
796edf36db
commit
a38824db27
9 changed files with 337 additions and 271 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)";
|
||||||
|
|
|
||||||
|
|
@ -548,7 +548,3 @@ struct ApiErrorBody: Decodable {
|
||||||
let error: ErrorObj?
|
let error: ErrorObj?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlayerTrack: Identifiable, Hashable {
|
|
||||||
let id: Int32
|
|
||||||
let name: String
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue