Compare commits
No commits in common. "b783ba137f70f81eb0aa462945a452fff8aaf5d8" and "221b98190ac4104c411ed290dc9e4d1010e1e149" have entirely different histories.
b783ba137f
...
221b98190a
17 changed files with 443 additions and 3359 deletions
|
|
@ -33,7 +33,6 @@
|
||||||
668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A435950EF040DE83F30779 /* DetailScreens.swift */; };
|
668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A435950EF040DE83F30779 /* DetailScreens.swift */; };
|
||||||
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; };
|
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; };
|
||||||
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */; };
|
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */; };
|
||||||
B7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */; };
|
|
||||||
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */; };
|
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */; };
|
||||||
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 */; };
|
||||||
|
|
@ -70,7 +69,6 @@
|
||||||
F0A435950EF040DE83F30779 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
|
F0A435950EF040DE83F30779 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
|
||||||
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = "<group>"; };
|
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = "<group>"; };
|
||||||
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsScreen.swift; sourceTree = "<group>"; };
|
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsScreen.swift; sourceTree = "<group>"; };
|
||||||
A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventHubsScreens.swift; sourceTree = "<group>"; };
|
|
||||||
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
|
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
|
||||||
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = "<group>"; };
|
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
|
|
@ -188,7 +186,6 @@
|
||||||
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */,
|
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */,
|
||||||
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
|
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
|
||||||
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */,
|
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */,
|
||||||
A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */,
|
|
||||||
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
|
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
|
||||||
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */,
|
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */,
|
||||||
);
|
);
|
||||||
|
|
@ -294,7 +291,6 @@
|
||||||
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */,
|
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */,
|
||||||
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */,
|
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */,
|
||||||
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */,
|
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */,
|
||||||
B7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift in Sources */,
|
|
||||||
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
|
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
|
||||||
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */,
|
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|
@ -345,7 +341,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 = 62;
|
CURRENT_PROJECT_VERSION = 52;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -360,7 +356,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.49;
|
MARKETING_VERSION = 0.5.39;
|
||||||
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)";
|
||||||
|
|
@ -378,7 +374,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 = 62;
|
CURRENT_PROJECT_VERSION = 52;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -393,7 +389,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.49;
|
MARKETING_VERSION = 0.5.39;
|
||||||
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)";
|
||||||
|
|
|
||||||
|
|
@ -344,39 +344,6 @@ actor ApiClient {
|
||||||
return try await get("/api/v1/client/events/\(enc)/play")
|
return try await get("/api/v1/client/events/\(enc)/play")
|
||||||
}
|
}
|
||||||
|
|
||||||
func eventHubs() async throws -> [EventHub] {
|
|
||||||
struct Wrap: Decodable { let hubs: [EventHub]? }
|
|
||||||
let wrap: Wrap = try await get("/api/v1/client/hubs")
|
|
||||||
return wrap.hubs ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
func f1Home() async throws -> F1HubPayload {
|
|
||||||
try await get("/api/v1/client/f1/home")
|
|
||||||
}
|
|
||||||
|
|
||||||
func f1Meeting(meetingKey: String) async throws -> F1MeetingDetail {
|
|
||||||
let enc = Self.encodePathSegment(meetingKey)
|
|
||||||
return try await get("/api/v1/client/f1/meetings/\(enc)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func f1ContentPlay(contentId: String) async throws -> IptvPlayInfo {
|
|
||||||
let enc = Self.encodePathSegment(contentId)
|
|
||||||
return try await get("/api/v1/client/f1/content/\(enc)/play")
|
|
||||||
}
|
|
||||||
|
|
||||||
func viaplayHome() async throws -> ViaplayHubPayload {
|
|
||||||
try await get("/api/v1/client/viaplay/home")
|
|
||||||
}
|
|
||||||
|
|
||||||
func viaplayContentPlay(guid: String, productUrl: String?) async throws -> IptvPlayInfo {
|
|
||||||
let enc = Self.encodePathSegment(guid)
|
|
||||||
var q = ""
|
|
||||||
if let productUrl = productUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !productUrl.isEmpty {
|
|
||||||
q = "?productUrl=\(Self.encodePathSegment(productUrl))"
|
|
||||||
}
|
|
||||||
return try await get("/api/v1/client/viaplay/content/\(enc)/play\(q)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode path segment (incl. `:` in `custom:uuid`) — strenger dan urlPathAllowed.
|
/// Encode path segment (incl. `:` in `custom:uuid`) — strenger dan urlPathAllowed.
|
||||||
private static func encodePathSegment(_ raw: String) -> String {
|
private static func encodePathSegment(_ raw: String) -> String {
|
||||||
var allowed = CharacterSet.alphanumerics
|
var allowed = CharacterSet.alphanumerics
|
||||||
|
|
|
||||||
|
|
@ -593,172 +593,3 @@ struct ApiErrorBody: Decodable {
|
||||||
let error: ErrorObj?
|
let error: ErrorObj?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Event hubs (F1 / Viaplay)
|
|
||||||
|
|
||||||
struct EventHub: Decodable, Identifiable, Hashable {
|
|
||||||
let id: String
|
|
||||||
let label: String
|
|
||||||
let subtitle: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct F1Meeting: Decodable, Identifiable, Hashable {
|
|
||||||
var id: String { meetingKey }
|
|
||||||
let meetingKey: String
|
|
||||||
let title: String
|
|
||||||
let meetingNumber: Int?
|
|
||||||
let location: String?
|
|
||||||
let startMs: Double?
|
|
||||||
let endMs: Double?
|
|
||||||
let imageUrl: String?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case meetingKey, title, meetingNumber, location, startMs, endMs, imageUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
meetingKey = try decodeFlexibleString(c, forKey: .meetingKey)
|
|
||||||
title = try c.decodeIfPresent(String.self, forKey: .title) ?? ""
|
|
||||||
meetingNumber = try? c.decodeIfPresent(Int.self, forKey: .meetingNumber)
|
|
||||||
location = try c.decodeIfPresent(String.self, forKey: .location)
|
|
||||||
startMs = try? c.decodeIfPresent(Double.self, forKey: .startMs)
|
|
||||||
endMs = try? c.decodeIfPresent(Double.self, forKey: .endMs)
|
|
||||||
imageUrl = try c.decodeIfPresent(String.self, forKey: .imageUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct F1ReplayGroup: Decodable, Identifiable, Hashable {
|
|
||||||
var id: String { meetingKey }
|
|
||||||
let meetingKey: String
|
|
||||||
let title: String
|
|
||||||
let location: String?
|
|
||||||
let meetingNumber: Int?
|
|
||||||
let imageUrl: String?
|
|
||||||
let latestStart: String?
|
|
||||||
let sessions: [ScheduleEvent]
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case meetingKey, title, location, meetingNumber, imageUrl, latestStart, sessions
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
meetingKey = try decodeFlexibleString(c, forKey: .meetingKey)
|
|
||||||
title = try c.decodeIfPresent(String.self, forKey: .title) ?? ""
|
|
||||||
location = try c.decodeIfPresent(String.self, forKey: .location)
|
|
||||||
meetingNumber = try? c.decodeIfPresent(Int.self, forKey: .meetingNumber)
|
|
||||||
imageUrl = try c.decodeIfPresent(String.self, forKey: .imageUrl)
|
|
||||||
latestStart = try c.decodeIfPresent(String.self, forKey: .latestStart)
|
|
||||||
sessions = try c.decodeIfPresent([ScheduleEvent].self, forKey: .sessions) ?? []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct F1HubPayload: Decodable {
|
|
||||||
let live: [ScheduleEvent]
|
|
||||||
let upcoming: [ScheduleEvent]
|
|
||||||
let recentReplays: [ScheduleEvent]
|
|
||||||
let replayGroups: [F1ReplayGroup]
|
|
||||||
let meetings: [F1Meeting]
|
|
||||||
let nextMeeting: F1Meeting?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case live, upcoming, recentReplays, replayGroups, meetings, nextMeeting
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
live = try c.decodeIfPresent([ScheduleEvent].self, forKey: .live) ?? []
|
|
||||||
upcoming = try c.decodeIfPresent([ScheduleEvent].self, forKey: .upcoming) ?? []
|
|
||||||
recentReplays = try c.decodeIfPresent([ScheduleEvent].self, forKey: .recentReplays) ?? []
|
|
||||||
replayGroups = try c.decodeIfPresent([F1ReplayGroup].self, forKey: .replayGroups) ?? []
|
|
||||||
meetings = try c.decodeIfPresent([F1Meeting].self, forKey: .meetings) ?? []
|
|
||||||
nextMeeting = try c.decodeIfPresent(F1Meeting.self, forKey: .nextMeeting)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct F1MeetingDetail: Decodable {
|
|
||||||
let meeting: F1Meeting?
|
|
||||||
let sessions: [ScheduleEvent]
|
|
||||||
let replays: [ScheduleEvent]
|
|
||||||
let upcoming: [ScheduleEvent]
|
|
||||||
let live: [ScheduleEvent]
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case meeting, sessions, replays, upcoming, live
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
meeting = try c.decodeIfPresent(F1Meeting.self, forKey: .meeting)
|
|
||||||
sessions = try c.decodeIfPresent([ScheduleEvent].self, forKey: .sessions) ?? []
|
|
||||||
replays = try c.decodeIfPresent([ScheduleEvent].self, forKey: .replays) ?? []
|
|
||||||
upcoming = try c.decodeIfPresent([ScheduleEvent].self, forKey: .upcoming) ?? []
|
|
||||||
live = try c.decodeIfPresent([ScheduleEvent].self, forKey: .live) ?? []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ViaplaySport: Decodable, Identifiable, Hashable {
|
|
||||||
let id: String
|
|
||||||
let label: String
|
|
||||||
let count: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ViaplayHubItem: Decodable, Identifiable, Hashable {
|
|
||||||
let id: String
|
|
||||||
let guid: String
|
|
||||||
let title: String
|
|
||||||
let subtitle: String?
|
|
||||||
let imageUrl: String?
|
|
||||||
let kind: String?
|
|
||||||
let openAs: String?
|
|
||||||
let playable: Bool?
|
|
||||||
let durationLabel: String?
|
|
||||||
let start: String?
|
|
||||||
let badge: String?
|
|
||||||
let pageUrl: String?
|
|
||||||
|
|
||||||
var opensAsEvent: Bool { (openAs ?? "").lowercased() == "event" }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ViaplayHubRail: Decodable, Identifiable, Hashable {
|
|
||||||
let id: String
|
|
||||||
let title: String
|
|
||||||
let items: [ViaplayHubItem]
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case id, title, items }
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
id = try c.decode(String.self, forKey: .id)
|
|
||||||
title = try c.decodeIfPresent(String.self, forKey: .title) ?? ""
|
|
||||||
items = (try? c.decodeIfPresent([ViaplayHubItem].self, forKey: .items)) ?? []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ViaplayHubPayload: Decodable {
|
|
||||||
let live: [ScheduleEvent]
|
|
||||||
let today: [ScheduleEvent]
|
|
||||||
let weekend: [ScheduleEvent]
|
|
||||||
let later: [ScheduleEvent]
|
|
||||||
let catchup: [ScheduleEvent]
|
|
||||||
let sports: [ViaplaySport]
|
|
||||||
let events: [ScheduleEvent]
|
|
||||||
let rails: [ViaplayHubRail]
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case live, today, weekend, later, catchup, sports, events, rails
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
live = try c.decodeIfPresent([ScheduleEvent].self, forKey: .live) ?? []
|
|
||||||
today = try c.decodeIfPresent([ScheduleEvent].self, forKey: .today) ?? []
|
|
||||||
weekend = try c.decodeIfPresent([ScheduleEvent].self, forKey: .weekend) ?? []
|
|
||||||
later = try c.decodeIfPresent([ScheduleEvent].self, forKey: .later) ?? []
|
|
||||||
catchup = try c.decodeIfPresent([ScheduleEvent].self, forKey: .catchup) ?? []
|
|
||||||
sports = try c.decodeIfPresent([ViaplaySport].self, forKey: .sports) ?? []
|
|
||||||
events = try c.decodeIfPresent([ScheduleEvent].self, forKey: .events) ?? []
|
|
||||||
rails = try c.decodeIfPresent([ViaplayHubRail].self, forKey: .rails) ?? []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,6 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
@Published var subtitleOptions: [AVMediaOptionItem] = []
|
@Published var subtitleOptions: [AVMediaOptionItem] = []
|
||||||
@Published var currentAudioId: String?
|
@Published var currentAudioId: String?
|
||||||
@Published var currentSubtitleId: String?
|
@Published var currentSubtitleId: String?
|
||||||
/// True zodra we minstens één keer echt speelden — timeout mag dan niet meer afbreken.
|
|
||||||
private var everPlayed = false
|
|
||||||
|
|
||||||
let player = AVPlayer()
|
let player = AVPlayer()
|
||||||
private var proxySession = 0
|
private var proxySession = 0
|
||||||
|
|
@ -55,7 +53,6 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
hasError = false
|
hasError = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
hasVideo = false
|
hasVideo = false
|
||||||
everPlayed = false
|
|
||||||
audioOptions = []
|
audioOptions = []
|
||||||
subtitleOptions = []
|
subtitleOptions = []
|
||||||
currentAudioId = nil
|
currentAudioId = nil
|
||||||
|
|
@ -133,25 +130,11 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshMediaOptions() {
|
func refreshMediaOptions() {
|
||||||
// ClearKey live: sporen uit de proxy (niet HLS multi-rendition — dat gaf -12312).
|
|
||||||
let tracks = ClearKeyDashProxy.shared.availableAudioTracks()
|
|
||||||
if !tracks.isEmpty {
|
|
||||||
audioOptions = tracks.map { AVMediaOptionItem(id: $0.id, name: $0.name) }
|
|
||||||
currentAudioId = ClearKeyDashProxy.shared.preferredAudioId ?? tracks.first?.id
|
|
||||||
}
|
|
||||||
guard let item = player.currentItem else { return }
|
guard let item = player.currentItem else { return }
|
||||||
Task { await loadMediaOptions(for: item) }
|
Task { await loadMediaOptions(for: item) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectAudio(id: String) {
|
func selectAudio(id: String) {
|
||||||
// Proxy-spoor wisselen + item herladen (geen seamless HLS AUDIO switch).
|
|
||||||
if ClearKeyDashProxy.shared.setPreferredAudio(id: id) {
|
|
||||||
currentAudioId = id
|
|
||||||
hasError = false
|
|
||||||
errorMessage = nil
|
|
||||||
Task { await reloadCurrentItemKeepingProxy() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let item = player.currentItem, let group = audioGroup,
|
guard let item = player.currentItem, let group = audioGroup,
|
||||||
let option = audioOptionById[id] else { return }
|
let option = audioOptionById[id] else { return }
|
||||||
item.select(option, in: group)
|
item.select(option, in: group)
|
||||||
|
|
@ -169,15 +152,6 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Herlaad dezelfde lokale HLS-URL na audio-wissel zonder proxy te stoppen.
|
|
||||||
private func reloadCurrentItemKeepingProxy() async {
|
|
||||||
guard let asset = player.currentItem?.asset as? AVURLAsset else { return }
|
|
||||||
let url = asset.url
|
|
||||||
let gen = generation
|
|
||||||
stateText = "Audio wisselen…"
|
|
||||||
await startItem(url: url, gen: gen)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private func startItem(url: URL, gen: Int) async {
|
private func startItem(url: URL, gen: Int) async {
|
||||||
|
|
@ -188,7 +162,7 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
AVURLAssetAllowsConstrainedNetworkAccessKey: true,
|
AVURLAssetAllowsConstrainedNetworkAccessKey: true,
|
||||||
])
|
])
|
||||||
let item = AVPlayerItem(asset: asset)
|
let item = AVPlayerItem(asset: asset)
|
||||||
item.preferredForwardBufferDuration = 6
|
item.preferredForwardBufferDuration = 8
|
||||||
clearObservers()
|
clearObservers()
|
||||||
|
|
||||||
itemStatusObs = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in
|
itemStatusObs = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in
|
||||||
|
|
@ -199,10 +173,8 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
self.stateText = "Afspelen"
|
self.stateText = "Afspelen"
|
||||||
self.hasError = false
|
self.hasError = false
|
||||||
self.refreshHasVideo(from: item)
|
self.refreshHasVideo(from: item)
|
||||||
// Eerst play — loadMediaOptions mag start niet blokkeren (Prime/HBO voelde traag).
|
|
||||||
self.player.play()
|
|
||||||
self.everPlayed = true
|
|
||||||
await self.loadMediaOptions(for: item)
|
await self.loadMediaOptions(for: item)
|
||||||
|
self.player.play()
|
||||||
case .failed:
|
case .failed:
|
||||||
self.fail(from: item.error, gen: gen)
|
self.fail(from: item.error, gen: gen)
|
||||||
default:
|
default:
|
||||||
|
|
@ -217,7 +189,6 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
switch player.timeControlStatus {
|
switch player.timeControlStatus {
|
||||||
case .playing:
|
case .playing:
|
||||||
self.isPlaying = true
|
self.isPlaying = true
|
||||||
self.everPlayed = true
|
|
||||||
self.stateText = "Afspelen"
|
self.stateText = "Afspelen"
|
||||||
self.hasError = false
|
self.hasError = false
|
||||||
if let item = player.currentItem {
|
if let item = player.currentItem {
|
||||||
|
|
@ -225,8 +196,7 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
case .waitingToPlayAtSpecifiedRate:
|
case .waitingToPlayAtSpecifiedRate:
|
||||||
self.isPlaying = false
|
self.isPlaying = false
|
||||||
// Na first-play: bufferen is normaal — geen "Fout"-timeout.
|
self.stateText = "Bufferen…"
|
||||||
self.stateText = self.everPlayed ? "Bufferen…" : "Bufferen…"
|
|
||||||
case .paused:
|
case .paused:
|
||||||
self.isPlaying = false
|
self.isPlaying = false
|
||||||
if !self.hasError { self.stateText = "Pauze" }
|
if !self.hasError { self.stateText = "Pauze" }
|
||||||
|
|
@ -258,8 +228,6 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
if let events = item.errorLog()?.events, let last = events.last {
|
if let events = item.errorLog()?.events, let last = events.last {
|
||||||
let comment = last.errorComment ?? last.errorDomain
|
let comment = last.errorComment ?? last.errorDomain
|
||||||
self.proxyLogState("AV errorLog: \(last.errorStatusCode) \(comment ?? "")")
|
self.proxyLogState("AV errorLog: \(last.errorStatusCode) \(comment ?? "")")
|
||||||
// -12312 bij audio-wissel: niet meteen als fatale UI-fout tonen.
|
|
||||||
if last.errorStatusCode == -12312 { return }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -268,21 +236,20 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
player.automaticallyWaitsToMinimizeStalling = true
|
player.automaticallyWaitsToMinimizeStalling = true
|
||||||
player.play()
|
player.play()
|
||||||
|
|
||||||
// Timeout alleen als er NOOIT iets speelde — nooit een werkende/bufferende stream afbreken.
|
// Timeout alleen als er NOG NIETS speelt — nooit een werkende stream afbreken.
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000_000)
|
try? await Task.sleep(nanoseconds: 15_000_000_000)
|
||||||
guard self.generation == gen, !self.stopping else { return }
|
guard self.generation == gen, !self.stopping else { return }
|
||||||
if let item = self.player.currentItem {
|
if let item = self.player.currentItem {
|
||||||
self.refreshHasVideo(from: item)
|
self.refreshHasVideo(from: item)
|
||||||
}
|
}
|
||||||
if self.everPlayed || self.isPlaying { return }
|
if !self.isPlaying && !self.hasError {
|
||||||
if !self.hasError {
|
|
||||||
if let err = item.error {
|
if let err = item.error {
|
||||||
self.fail(from: err, gen: gen)
|
self.fail(from: err, gen: gen)
|
||||||
} else {
|
} else {
|
||||||
self.hasError = true
|
self.hasError = true
|
||||||
self.errorMessage = self.bestErrorMessage(for: item)
|
self.errorMessage = self.bestErrorMessage(for: item)
|
||||||
?? "Stream opende niet binnen 20s (ClearKey/HLS)."
|
?? "Stream opende niet binnen 15s (ClearKey/HLS)."
|
||||||
self.stateText = "Fout"
|
self.stateText = "Fout"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -303,13 +270,6 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
private func loadMediaOptions(for item: AVPlayerItem) async {
|
private func loadMediaOptions(for item: AVPlayerItem) async {
|
||||||
let asset = item.asset
|
let asset = item.asset
|
||||||
do {
|
do {
|
||||||
let proxyTracks = ClearKeyDashProxy.shared.availableAudioTracks()
|
|
||||||
if !proxyTracks.isEmpty {
|
|
||||||
audioOptions = proxyTracks.map { AVMediaOptionItem(id: $0.id, name: $0.name) }
|
|
||||||
audioOptionById = [:]
|
|
||||||
audioGroup = nil
|
|
||||||
currentAudioId = ClearKeyDashProxy.shared.preferredAudioId ?? proxyTracks.first?.id
|
|
||||||
} else {
|
|
||||||
let audible = try await asset.loadMediaSelectionGroup(for: .audible)
|
let audible = try await asset.loadMediaSelectionGroup(for: .audible)
|
||||||
audioGroup = audible
|
audioGroup = audible
|
||||||
if let audible {
|
if let audible {
|
||||||
|
|
@ -333,7 +293,6 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
audioOptions = []
|
audioOptions = []
|
||||||
audioOptionById = [:]
|
audioOptionById = [:]
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let legible = try await asset.loadMediaSelectionGroup(for: .legible)
|
let legible = try await asset.loadMediaSelectionGroup(for: .legible)
|
||||||
subtitleGroup = legible
|
subtitleGroup = legible
|
||||||
|
|
@ -366,12 +325,6 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
|
|
||||||
private func fail(from error: Error?, gen: Int) {
|
private func fail(from error: Error?, gen: Int) {
|
||||||
guard generation == gen, !stopping else { return }
|
guard generation == gen, !stopping else { return }
|
||||||
let ns = (error as NSError?)
|
|
||||||
// Playlist-mismatch tijdens live/audio-wissel — negeren als we nog (opnieuw) laden.
|
|
||||||
if let ns, ns.domain == "CoreMediaErrorDomain", ns.code == -12312 {
|
|
||||||
proxyLogState("ignore -12312: \(ns.localizedDescription)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hasError = true
|
hasError = true
|
||||||
if let error {
|
if let error {
|
||||||
errorMessage = dutchAVError(error)
|
errorMessage = dutchAVError(error)
|
||||||
|
|
@ -380,24 +333,13 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
} else {
|
} else {
|
||||||
errorMessage = "Kon stream niet openen"
|
errorMessage = "Kon stream niet openen"
|
||||||
}
|
}
|
||||||
// Toon geen -12312 als enige melding.
|
|
||||||
if let msg = errorMessage, msg.contains("-12312") {
|
|
||||||
errorMessage = nil
|
|
||||||
hasError = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stateText = "Fout"
|
stateText = "Fout"
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bestErrorMessage(for item: AVPlayerItem) -> String? {
|
private func bestErrorMessage(for item: AVPlayerItem) -> String? {
|
||||||
if let err = item.error {
|
if let err = item.error { return dutchAVError(err) }
|
||||||
let ns = err as NSError
|
|
||||||
if ns.domain == "CoreMediaErrorDomain", ns.code == -12312 { return nil }
|
|
||||||
return dutchAVError(err)
|
|
||||||
}
|
|
||||||
if let events = item.errorLog()?.events, let last = events.last {
|
if let events = item.errorLog()?.events, let last = events.last {
|
||||||
if last.errorStatusCode == -12312 { return nil }
|
|
||||||
let code = last.errorStatusCode
|
let code = last.errorStatusCode
|
||||||
let comment = last.errorComment ?? last.errorDomain
|
let comment = last.errorComment ?? last.errorDomain
|
||||||
return "Afspeelfout (\(code)): \(comment)"
|
return "Afspeelfout (\(code)): \(comment)"
|
||||||
|
|
@ -452,55 +394,24 @@ final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AVPlayerLayer i.p.v. AVPlayerViewController:
|
struct AVLivePlayerView: UIViewControllerRepresentable {
|
||||||
/// - schaalt mee bij rotatie zonder seek/rebind (geen haperen)
|
|
||||||
/// - voorkomt ESPN “alleen audio” na landscape (AVPlayerViewController-compositor-bug)
|
|
||||||
struct AVLivePlayerView: UIViewRepresentable {
|
|
||||||
let controller: AVLivePlayerController
|
let controller: AVLivePlayerController
|
||||||
|
|
||||||
func makeUIView(context: Context) -> AVLivePlayerLayerView {
|
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||||
let view = AVLivePlayerLayerView()
|
let vc = AVPlayerViewController()
|
||||||
view.player = controller.player
|
vc.player = controller.player
|
||||||
return view
|
vc.showsPlaybackControls = false
|
||||||
|
vc.videoGravity = .resizeAspect
|
||||||
|
vc.allowsPictureInPicturePlayback = true
|
||||||
|
if #available(iOS 14.2, *) {
|
||||||
|
vc.canStartPictureInPictureAutomaticallyFromInline = true
|
||||||
|
}
|
||||||
|
return vc
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: AVLivePlayerLayerView, context: Context) {
|
func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {
|
||||||
if uiView.player !== controller.player {
|
if vc.player !== controller.player {
|
||||||
uiView.player = controller.player
|
vc.player = controller.player
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func dismantleUIView(_ uiView: AVLivePlayerLayerView, coordinator: ()) {
|
|
||||||
uiView.player = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class AVLivePlayerLayerView: UIView {
|
|
||||||
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
|
||||||
|
|
||||||
private var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
|
||||||
|
|
||||||
var player: AVPlayer? {
|
|
||||||
get { playerLayer.player }
|
|
||||||
set {
|
|
||||||
playerLayer.player = newValue
|
|
||||||
playerLayer.videoGravity = .resizeAspect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
super.init(frame: frame)
|
|
||||||
backgroundColor = .black
|
|
||||||
isUserInteractionEnabled = false
|
|
||||||
playerLayer.videoGravity = .resizeAspect
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
playerLayer.frame = bounds
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,7 @@ enum AesCtr {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CBCS pattern decrypt — gelijk aan Chromium `DecryptWithPattern` (ISO 23001-7 §10.4):
|
/// CBCS pattern: decrypt `crypt` 16-byte blocks, skip `skip` blocks; leftover <16 clear.
|
||||||
/// - IV wordt één keer gezet per protected range (subsample), niet per pattern.
|
|
||||||
/// - Crypt-blokken vormen één CBC-keten; skip-blokken blijven clear en updaten de IV niet.
|
|
||||||
/// - Pattern 0:0 = alle volle blokken encrypted (geen pattern).
|
|
||||||
/// - Partiële trailing bytes blijven clear.
|
|
||||||
static func cryptCbcs(
|
static func cryptCbcs(
|
||||||
key: Data,
|
key: Data,
|
||||||
iv: Data,
|
iv: Data,
|
||||||
|
|
@ -64,59 +60,31 @@ enum AesCtr {
|
||||||
skipByteBlock: Int
|
skipByteBlock: Int
|
||||||
) -> Data? {
|
) -> Data? {
|
||||||
guard key.count == 16 else { return nil }
|
guard key.count == 16 else { return nil }
|
||||||
if data.isEmpty { return data }
|
if cryptByteBlock <= 0 {
|
||||||
|
return data
|
||||||
let blockSize = 16
|
|
||||||
let totalBlocks = data.count / blockSize
|
|
||||||
guard totalBlocks > 0 else { return data }
|
|
||||||
|
|
||||||
var cryptBlocks = cryptByteBlock
|
|
||||||
var skipBlocks = skipByteBlock
|
|
||||||
// ISO/Chromium: 0:0 → alles encrypted als één CBC-run.
|
|
||||||
if cryptBlocks == 0 && skipBlocks == 0 {
|
|
||||||
cryptBlocks = totalBlocks
|
|
||||||
skipBlocks = 0
|
|
||||||
}
|
}
|
||||||
if cryptBlocks <= 0 { return data }
|
let cryptLen = 16 * cryptByteBlock
|
||||||
|
let skipLen = 16 * max(skipByteBlock, 0)
|
||||||
var out = data
|
var result = data
|
||||||
var blockIv = Data(count: 16)
|
|
||||||
let ivn = min(iv.count, 16)
|
|
||||||
blockIv.replaceSubrange(0..<ivn, with: iv.prefix(ivn))
|
|
||||||
|
|
||||||
var blocksProcessed = 0
|
|
||||||
var offset = 0
|
var offset = 0
|
||||||
// Chromium: start met encrypted run (toggle van false → true).
|
var blockIv = Data(count: 16)
|
||||||
var isCryptRun = false
|
let ivCopy = min(iv.count, 16)
|
||||||
|
blockIv.replaceSubrange(0..<ivCopy, with: iv.prefix(ivCopy))
|
||||||
|
|
||||||
while blocksProcessed < totalBlocks {
|
while offset < result.count {
|
||||||
isCryptRun = !isCryptRun
|
let remaining = result.count - offset
|
||||||
let runSize = isCryptRun ? cryptBlocks : skipBlocks
|
if remaining < cryptLen { break }
|
||||||
if runSize == 0 { continue }
|
let slice = result.subdata(in: offset..<(offset + cryptLen))
|
||||||
|
guard let plain = aesCbcDecryptNoPad(key: key, iv: blockIv, data: slice) else { return nil }
|
||||||
let blocksToProcess = min(runSize, totalBlocks - blocksProcessed)
|
result.replaceSubrange(offset..<(offset + cryptLen), with: plain)
|
||||||
if blocksToProcess == 0 { continue }
|
// Next IV = last ciphertext block (CBC chain within crypt run).
|
||||||
|
blockIv = slice.suffix(16)
|
||||||
let byteCount = blocksToProcess * blockSize
|
offset += cryptLen
|
||||||
let range = offset..<(offset + byteCount)
|
let skip = min(skipLen, result.count - offset)
|
||||||
|
offset += skip
|
||||||
if isCryptRun {
|
// Pattern: IV resets per subsample in caller; within subsample after skip, continue with updated IV.
|
||||||
let cipher = out.subdata(in: range)
|
|
||||||
guard let plain = aesCbcDecryptNoPad(key: key, iv: blockIv, data: cipher) else {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
out.replaceSubrange(range, with: plain)
|
return result
|
||||||
// CBC-keten over crypt-runs: IV = laatste ciphertext-blok.
|
|
||||||
if cipher.count >= blockSize {
|
|
||||||
blockIv.replaceSubrange(0..<blockSize, with: cipher.suffix(blockSize))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Skip-run: clear laten; IV niet aanpassen.
|
|
||||||
|
|
||||||
offset += byteCount
|
|
||||||
blocksProcessed += blocksToProcess
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func aesCbcDecryptNoPad(key: Data, iv: Data, data: Data) -> Data? {
|
private static func aesCbcDecryptNoPad(key: Data, iv: Data, data: Data) -> Data? {
|
||||||
|
|
|
||||||
|
|
@ -16,31 +16,9 @@ enum CencMp4Decryptor {
|
||||||
var fallback: TrackCrypto?
|
var fallback: TrackCrypto?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Diagnose-hook (alleen aanroepen onder de crypto-lock van de proxy).
|
|
||||||
static var diagTag = ""
|
|
||||||
static var diagSink: ((String) -> Void)?
|
|
||||||
private static var diagCounts: [String: Int] = [:]
|
|
||||||
|
|
||||||
private static func diag(_ message: @autoclosure () -> String) {
|
|
||||||
guard let sink = diagSink else { return }
|
|
||||||
let n = diagCounts[diagTag, default: 0]
|
|
||||||
guard n < 8 else { return }
|
|
||||||
diagCounts[diagTag] = n + 1
|
|
||||||
sink("decrypt[\(diagTag)] \(message())")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resetDiag() {
|
|
||||||
diagCounts.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hex(_ d: Data) -> String {
|
|
||||||
d.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func process(data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) -> Bool {
|
static func process(data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) -> Bool {
|
||||||
guard !data.isEmpty, !keys.isEmpty else { return false }
|
guard !data.isEmpty, !keys.isEmpty else { return false }
|
||||||
// Nooit crashen op corrupte segmenten — liever ongedecrypt doorgeven dan app-kill.
|
|
||||||
var touched = false
|
var touched = false
|
||||||
if containsBox(data, type: "moov") {
|
if containsBox(data, type: "moov") {
|
||||||
harvestAndClearInit(&data, keys: keys, state: &state)
|
harvestAndClearInit(&data, keys: keys, state: &state)
|
||||||
|
|
@ -89,45 +67,19 @@ enum CencMp4Decryptor {
|
||||||
let containers: Set<String> = [
|
let containers: Set<String> = [
|
||||||
"moov", "trak", "mdia", "minf", "stbl", "moof", "traf",
|
"moov", "trak", "mdia", "minf", "stbl", "moof", "traf",
|
||||||
"mvex", "edts", "sinf", "schi", "dinf", "udta", "meta",
|
"mvex", "edts", "sinf", "schi", "dinf", "udta", "meta",
|
||||||
// Sample-entries bevatten sinf/avcC; stsd is FullBox met entry_count.
|
|
||||||
"stsd", "dref",
|
|
||||||
"encv", "enca",
|
|
||||||
"avc1", "avc3", "hev1", "hvc1", "dvh1", "mp4a",
|
|
||||||
]
|
]
|
||||||
while offset + 8 <= to {
|
while offset + 8 <= to {
|
||||||
guard let box = readBox(data, at: offset, limit: to) else { break }
|
guard let box = readBox(data, at: offset, limit: to) else { break }
|
||||||
let cont = visit(box)
|
let cont = visit(box)
|
||||||
if !cont { return }
|
if !cont { return }
|
||||||
if recursive, containers.contains(box.type), box.payloadLength > 0 {
|
if recursive, containers.contains(box.type), box.payloadLength > 0 {
|
||||||
let childFrom = walkChildrenStart(box)
|
walk(data, from: box.payloadStart, to: box.payloadEnd, recursive: true, visit: visit)
|
||||||
if childFrom < box.payloadEnd {
|
|
||||||
walk(data, from: childFrom, to: box.payloadEnd, recursive: true, visit: visit)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
offset = box.payloadEnd
|
offset = box.payloadEnd
|
||||||
if box.size <= 0 { break }
|
if box.size <= 0 { break }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Skip FullBox / SampleEntry vaste headers zodat child-boxes (sinf, tenc, avcC) bereikbaar zijn.
|
|
||||||
private static func walkChildrenStart(_ box: Box) -> Int {
|
|
||||||
switch box.type {
|
|
||||||
case "stsd", "dref":
|
|
||||||
// FullBox (4) + entry_count (4)
|
|
||||||
return box.payloadStart + 8
|
|
||||||
case "meta":
|
|
||||||
return box.payloadStart + 4
|
|
||||||
case "encv", "avc1", "avc3", "hev1", "hvc1", "dvh1":
|
|
||||||
// VisualSampleEntry: boxhdr(8) + SampleEntry(8) + visual(70) = 86
|
|
||||||
return box.start + 86
|
|
||||||
case "enca", "mp4a":
|
|
||||||
// AudioSampleEntry: boxhdr(8) + SampleEntry(8) + audio(20) = 36
|
|
||||||
return box.start + 36
|
|
||||||
default:
|
|
||||||
return box.payloadStart
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func readBox(_ data: Data, at offset: Int, limit: Int) -> Box? {
|
private static func readBox(_ data: Data, at offset: Int, limit: Int) -> Box? {
|
||||||
guard offset + 8 <= limit else { return nil }
|
guard offset + 8 <= limit else { return nil }
|
||||||
var size = Int(readU32(data, offset))
|
var size = Int(readU32(data, offset))
|
||||||
|
|
@ -146,23 +98,19 @@ enum CencMp4Decryptor {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readU16(_ data: Data, _ o: Int) -> UInt16 {
|
private static func readU16(_ data: Data, _ o: Int) -> UInt16 {
|
||||||
guard o >= 0, o + 2 <= data.count else { return 0 }
|
UInt16(data[o]) << 8 | UInt16(data[o + 1])
|
||||||
return UInt16(data[o]) << 8 | UInt16(data[o + 1])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readU32(_ data: Data, _ o: Int) -> UInt32 {
|
private static func readU32(_ data: Data, _ o: Int) -> UInt32 {
|
||||||
guard o >= 0, o + 4 <= data.count else { return 0 }
|
UInt32(data[o]) << 24 | UInt32(data[o + 1]) << 16 | UInt32(data[o + 2]) << 8 | UInt32(data[o + 3])
|
||||||
return UInt32(data[o]) << 24 | UInt32(data[o + 1]) << 16 | UInt32(data[o + 2]) << 8 | UInt32(data[o + 3])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readU64(_ data: Data, _ o: Int) -> UInt64 {
|
private static func readU64(_ data: Data, _ o: Int) -> UInt64 {
|
||||||
guard o >= 0, o + 8 <= data.count else { return 0 }
|
(UInt64(readU32(data, o)) << 32) | UInt64(readU32(data, o + 4))
|
||||||
return (UInt64(readU32(data, o)) << 32) | UInt64(readU32(data, o + 4))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readFourCC(_ data: Data, _ o: Int) -> String {
|
private static func readFourCC(_ data: Data, _ o: Int) -> String {
|
||||||
guard o >= 0, o + 4 <= data.count else { return "????" }
|
String(bytes: data[o..<(o + 4)], encoding: .ascii) ?? "????"
|
||||||
return String(bytes: data[o..<(o + 4)], encoding: .ascii) ?? "????"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func writeFourCC(_ data: inout Data, _ o: Int, _ s: String) {
|
private static func writeFourCC(_ data: inout Data, _ o: Int, _ s: String) {
|
||||||
|
|
@ -174,10 +122,6 @@ enum CencMp4Decryptor {
|
||||||
// MARK: - Init segment
|
// MARK: - Init segment
|
||||||
|
|
||||||
private static func harvestAndClearInit(_ data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) {
|
private static func harvestAndClearInit(_ data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) {
|
||||||
// Elke init is de bron van waarheid voor deze lane (CMAF track_ID is vaak 1 voor A én V).
|
|
||||||
state.byTrackId.removeAll(keepingCapacity: true)
|
|
||||||
state.fallback = nil
|
|
||||||
|
|
||||||
// Track id from tkhd, crypto from sinf/tenc/frma/schm inside same trak.
|
// Track id from tkhd, crypto from sinf/tenc/frma/schm inside same trak.
|
||||||
for trak in findBoxes(data, type: "trak", recursive: true) {
|
for trak in findBoxes(data, type: "trak", recursive: true) {
|
||||||
var trackId: UInt32?
|
var trackId: UInt32?
|
||||||
|
|
@ -274,19 +218,11 @@ enum CencMp4Decryptor {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let crypto {
|
if let crypto {
|
||||||
diag("init track=\(trackId ?? 0) fmt=\(originalFormat ?? "-") scheme=\(crypto.scheme) pat=\(crypto.cryptByteBlock):\(crypto.skipByteBlock) iv=\(crypto.perSampleIvSize) civ=\(crypto.constantIv?.count ?? 0) kid=\(hex(crypto.kid)) exact=\(keys.hasExactKey(forKid: crypto.kid)) keys=\(keys.uniqueKeyCount)")
|
|
||||||
if let trackId {
|
if let trackId {
|
||||||
state.byTrackId[trackId] = crypto
|
state.byTrackId[trackId] = crypto
|
||||||
}
|
}
|
||||||
// Fallback: prefer video-achtige crypto (cbcs / pattern) boven pure audio-cenc.
|
|
||||||
let looksVideo = crypto.scheme == "cbcs" || crypto.scheme == "cbc1"
|
|
||||||
|| crypto.cryptByteBlock > 0 || crypto.constantIv != nil
|
|
||||||
if state.fallback == nil || looksVideo {
|
|
||||||
state.fallback = crypto
|
state.fallback = crypto
|
||||||
}
|
|
||||||
_ = keys.key(forKid: crypto.kid)
|
_ = keys.key(forKid: crypto.kid)
|
||||||
} else {
|
|
||||||
diag("init track=\(trackId ?? 0) fmt=\(originalFormat ?? "-") clear (geen tenc)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -336,16 +272,10 @@ enum CencMp4Decryptor {
|
||||||
var dataOffsetPresent = false
|
var dataOffsetPresent = false
|
||||||
var sampleSizes: [Int] = []
|
var sampleSizes: [Int] = []
|
||||||
var sencSamples: [SencSample] = []
|
var sencSamples: [SencSample] = []
|
||||||
var perSampleIvSize = 8
|
var perSampleIvSize = state.fallback?.perSampleIvSize ?? 8
|
||||||
var tfhdFlagsSeen: UInt32 = 0
|
|
||||||
var trunFlagsSeen: UInt32 = 0
|
|
||||||
if let known = state.byTrackId.values.first, state.byTrackId.count == 1 {
|
|
||||||
perSampleIvSize = known.perSampleIvSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// tfhd
|
// tfhd
|
||||||
for tfhd in findBoxesInRange(data, type: "tfhd", from: traf.payloadStart, to: traf.payloadEnd) {
|
for tfhd in findBoxesInRange(data, type: "tfhd", from: traf.payloadStart, to: traf.payloadEnd) {
|
||||||
guard tfhd.payloadLength >= 8 else { continue }
|
|
||||||
let versionFlags = readU32(data, tfhd.payloadStart)
|
let versionFlags = readU32(data, tfhd.payloadStart)
|
||||||
let flags = versionFlags & 0x00FF_FFFF
|
let flags = versionFlags & 0x00FF_FFFF
|
||||||
var o = tfhd.payloadStart + 4
|
var o = tfhd.payloadStart + 4
|
||||||
|
|
@ -354,65 +284,42 @@ enum CencMp4Decryptor {
|
||||||
perSampleIvSize = crypto.perSampleIvSize
|
perSampleIvSize = crypto.perSampleIvSize
|
||||||
}
|
}
|
||||||
if flags & 0x000001 != 0 { // base-data-offset
|
if flags & 0x000001 != 0 { // base-data-offset
|
||||||
guard o + 8 <= tfhd.payloadEnd else { break }
|
|
||||||
baseDataOffset = Int64(readU64(data, o)); o += 8
|
baseDataOffset = Int64(readU64(data, o)); o += 8
|
||||||
dataOffsetPresent = true
|
dataOffsetPresent = true
|
||||||
}
|
}
|
||||||
if flags & 0x000002 != 0 { // sample-description-index
|
if flags & 0x000002 != 0 { o += 4 } // sample-description-index
|
||||||
guard o + 4 <= tfhd.payloadEnd else { break }
|
if flags & 0x000008 != 0 {
|
||||||
o += 4
|
|
||||||
}
|
|
||||||
if flags & 0x000008 != 0 { // default-sample-duration
|
|
||||||
guard o + 4 <= tfhd.payloadEnd else { break }
|
|
||||||
o += 4
|
|
||||||
}
|
|
||||||
if flags & 0x000010 != 0 { // default-sample-size
|
|
||||||
guard o + 4 <= tfhd.payloadEnd else { break }
|
|
||||||
defaultSampleSize = readU32(data, o); o += 4
|
defaultSampleSize = readU32(data, o); o += 4
|
||||||
}
|
}
|
||||||
tfhdFlagsSeen = flags
|
|
||||||
_ = versionFlags
|
_ = versionFlags
|
||||||
}
|
}
|
||||||
|
|
||||||
// trun
|
// trun
|
||||||
for trun in findBoxesInRange(data, type: "trun", from: traf.payloadStart, to: traf.payloadEnd) {
|
for trun in findBoxesInRange(data, type: "trun", from: traf.payloadStart, to: traf.payloadEnd) {
|
||||||
guard trun.payloadLength >= 8 else { continue }
|
|
||||||
let versionFlags = readU32(data, trun.payloadStart)
|
let versionFlags = readU32(data, trun.payloadStart)
|
||||||
let version = Int(versionFlags >> 24)
|
let version = Int(versionFlags >> 24)
|
||||||
let flags = versionFlags & 0x00FF_FFFF
|
let flags = versionFlags & 0x00FF_FFFF
|
||||||
trunFlagsSeen = flags
|
|
||||||
var o = trun.payloadStart + 4
|
var o = trun.payloadStart + 4
|
||||||
let sampleCount = Int(readU32(data, o)); o += 4
|
let sampleCount = Int(readU32(data, o)); o += 4
|
||||||
guard sampleCount >= 0, sampleCount < 100_000 else { continue }
|
|
||||||
var dataOffset: Int32 = 0
|
var dataOffset: Int32 = 0
|
||||||
if flags & 0x000001 != 0 {
|
if flags & 0x000001 != 0 {
|
||||||
guard o + 4 <= trun.payloadEnd else { break }
|
|
||||||
dataOffset = Int32(bitPattern: readU32(data, o)); o += 4
|
dataOffset = Int32(bitPattern: readU32(data, o)); o += 4
|
||||||
dataOffsetPresent = true
|
dataOffsetPresent = true
|
||||||
}
|
}
|
||||||
if flags & 0x000004 != 0 {
|
if flags & 0x000004 != 0 { o += 4 } // first-sample-flags
|
||||||
guard o + 4 <= trun.payloadEnd else { break }
|
|
||||||
o += 4
|
|
||||||
}
|
|
||||||
sampleSizes.removeAll(keepingCapacity: true)
|
sampleSizes.removeAll(keepingCapacity: true)
|
||||||
for _ in 0..<sampleCount {
|
for _ in 0..<sampleCount {
|
||||||
if flags & 0x000100 != 0 {
|
if flags & 0x000100 != 0 { // sample-duration
|
||||||
guard o + 4 <= trun.payloadEnd else { break }
|
|
||||||
o += 4
|
o += 4
|
||||||
}
|
}
|
||||||
var size = Int(defaultSampleSize)
|
var size = Int(defaultSampleSize)
|
||||||
if flags & 0x000200 != 0 {
|
if flags & 0x000200 != 0 {
|
||||||
guard o + 4 <= trun.payloadEnd else { break }
|
|
||||||
size = Int(readU32(data, o)); o += 4
|
size = Int(readU32(data, o)); o += 4
|
||||||
}
|
}
|
||||||
sampleSizes.append(max(0, size))
|
sampleSizes.append(size)
|
||||||
if flags & 0x000400 != 0 {
|
if flags & 0x000400 != 0 { o += 4 } // sample-flags
|
||||||
guard o + 4 <= trun.payloadEnd else { break }
|
|
||||||
o += 4
|
|
||||||
}
|
|
||||||
if flags & 0x000800 != 0 {
|
if flags & 0x000800 != 0 {
|
||||||
guard o + 4 <= trun.payloadEnd else { break }
|
o += version == 0 ? 4 : 4 // sample-composition-time-offset
|
||||||
o += 4
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if dataOffsetPresent {
|
if dataOffsetPresent {
|
||||||
|
|
@ -433,10 +340,8 @@ enum CencMp4Decryptor {
|
||||||
_ = version
|
_ = version
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer track-crypto uit DÉZE lane. Fallback alleen binnen dezelfde lane.
|
// senc
|
||||||
let crypto: TrackCrypto? = state.byTrackId[trackId]
|
let crypto = state.byTrackId[trackId] ?? state.fallback
|
||||||
?? (state.byTrackId.count == 1 ? state.byTrackId.values.first : nil)
|
|
||||||
?? state.fallback
|
|
||||||
if let crypto {
|
if let crypto {
|
||||||
perSampleIvSize = crypto.perSampleIvSize
|
perSampleIvSize = crypto.perSampleIvSize
|
||||||
}
|
}
|
||||||
|
|
@ -456,24 +361,17 @@ enum CencMp4Decryptor {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cbcs audio (F1TV): tenc heeft constant_IV + iv_size=0, fragmenten hebben géén senc.
|
guard !sampleSizes.isEmpty, sencSamples.count == sampleSizes.count || sencSamples.isEmpty == false else {
|
||||||
// ISO 23001-7: elk sample ontsleutelen met die IV (geen per-sample IV, geen subsample).
|
|
||||||
let constantIv = crypto?.constantIv
|
|
||||||
let canUseConstantIv = (constantIv?.count ?? 0) >= 8
|
|
||||||
guard !sampleSizes.isEmpty, !sencSamples.isEmpty || canUseConstantIv else {
|
|
||||||
diag("frag track=\(trackId) skip: samples=\(sampleSizes.count) senc=\(sencSamples.count) tfhd=0x\(String(tfhdFlagsSeen, radix: 16)) trun=0x\(String(trunFlagsSeen, radix: 16))")
|
|
||||||
// Advance cursor even if we cannot decrypt.
|
// Advance cursor even if we cannot decrypt.
|
||||||
for sz in sampleSizes { mdatCursor += sz }
|
for sz in sampleSizes { mdatCursor += sz }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exacte KID-match, anders primaryKey als er maar één key is.
|
|
||||||
let key: Data? = {
|
let key: Data? = {
|
||||||
guard let crypto else { return keys.primaryKey }
|
if let crypto, let k = keys.key(forKid: crypto.kid) { return k }
|
||||||
return keys.key(forKid: crypto.kid)
|
return keys.primaryKey
|
||||||
}()
|
}()
|
||||||
guard let key else {
|
guard let key else {
|
||||||
diag("frag track=\(trackId) skip: geen key (kid=\(crypto.map { hex($0.kid) } ?? "-") keys=\(keys.uniqueKeyCount))")
|
|
||||||
for sz in sampleSizes { mdatCursor += sz }
|
for sz in sampleSizes { mdatCursor += sz }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -482,38 +380,28 @@ enum CencMp4Decryptor {
|
||||||
let cryptBlocks = crypto?.cryptByteBlock ?? 0
|
let cryptBlocks = crypto?.cryptByteBlock ?? 0
|
||||||
let skipBlocks = crypto?.skipByteBlock ?? 0
|
let skipBlocks = crypto?.skipByteBlock ?? 0
|
||||||
|
|
||||||
var decryptedCount = 0
|
|
||||||
for i in 0..<sampleSizes.count {
|
for i in 0..<sampleSizes.count {
|
||||||
let size = sampleSizes[i]
|
let size = sampleSizes[i]
|
||||||
guard size > 0, mdatCursor + size <= data.count else { break }
|
guard size > 0, mdatCursor + size <= data.count else { break }
|
||||||
let sampleRange = mdatCursor..<(mdatCursor + size)
|
let sampleRange = mdatCursor..<(mdatCursor + size)
|
||||||
var sample = data.subdata(in: sampleRange)
|
var sample = data.subdata(in: sampleRange)
|
||||||
let senc = i < sencSamples.count ? sencSamples[i] : nil
|
let senc = i < sencSamples.count ? sencSamples[i] : nil
|
||||||
let iv = senc?.iv ?? constantIv
|
if let senc {
|
||||||
if let iv {
|
|
||||||
if let decrypted = decryptSample(
|
if let decrypted = decryptSample(
|
||||||
sample,
|
sample,
|
||||||
key: key,
|
key: key,
|
||||||
iv: iv,
|
iv: senc.iv,
|
||||||
subsamples: senc?.subsamples,
|
subsamples: senc.subsamples,
|
||||||
scheme: scheme,
|
scheme: scheme,
|
||||||
cryptByteBlock: cryptBlocks,
|
cryptByteBlock: cryptBlocks,
|
||||||
skipByteBlock: skipBlocks
|
skipByteBlock: skipBlocks
|
||||||
) {
|
) {
|
||||||
sample = decrypted
|
sample = decrypted
|
||||||
data.replaceSubrange(sampleRange, with: sample)
|
data.replaceSubrange(sampleRange, with: sample)
|
||||||
decryptedCount += 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mdatCursor += size
|
mdatCursor += size
|
||||||
}
|
}
|
||||||
diag("frag track=\(trackId) crypto=\(crypto != nil) scheme=\(scheme) pat=\(cryptBlocks):\(skipBlocks) samples=\(sampleSizes.count) senc=\(sencSamples.count) iv=\(sencSamples.first?.iv.count ?? 0) subs=\(sencSamples.first?.subsamples?.count ?? -1) size0=\(sampleSizes.first ?? 0) dec=\(decryptedCount) tfhd=0x\(String(tfhdFlagsSeen, radix: 16)) trun=0x\(String(trunFlagsSeen, radix: 16)) mdat=\(mdat.payloadLength)")
|
|
||||||
// AVPlayer/VLC mogen geen tweede decrypt-poging doen op clear samples.
|
|
||||||
if decryptedCount > 0 {
|
|
||||||
for senc in findBoxesInRange(data, type: "senc", from: traf.payloadStart, to: traf.payloadEnd) {
|
|
||||||
writeFourCC(&data, senc.start + 4, "free")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -578,17 +466,11 @@ enum CencMp4Decryptor {
|
||||||
|
|
||||||
let saizFlags = readU32(data, saiz.payloadStart) & 0x00FF_FFFF
|
let saizFlags = readU32(data, saiz.payloadStart) & 0x00FF_FFFF
|
||||||
var o = saiz.payloadStart + 4
|
var o = saiz.payloadStart + 4
|
||||||
if saizFlags & 0x1 != 0 {
|
if saizFlags & 0x1 != 0 { o += 8 } // aux_info_type + parameter
|
||||||
guard o + 8 <= saiz.payloadEnd else { return [] }
|
|
||||||
o += 8
|
|
||||||
}
|
|
||||||
guard o + 5 <= saiz.payloadEnd else { return [] }
|
|
||||||
let defaultSize = Int(data[o]); o += 1
|
let defaultSize = Int(data[o]); o += 1
|
||||||
let count = Int(readU32(data, o)); o += 4
|
let count = Int(readU32(data, o)); o += 4
|
||||||
guard count >= 0, count < 100_000 else { return [] }
|
|
||||||
var sizes: [Int] = []
|
var sizes: [Int] = []
|
||||||
if defaultSize == 0 {
|
if defaultSize == 0 {
|
||||||
guard o + count <= saiz.payloadEnd else { return [] }
|
|
||||||
for _ in 0..<count {
|
for _ in 0..<count {
|
||||||
sizes.append(Int(data[o])); o += 1
|
sizes.append(Int(data[o])); o += 1
|
||||||
}
|
}
|
||||||
|
|
@ -663,7 +545,7 @@ enum CencMp4Decryptor {
|
||||||
key: key,
|
key: key,
|
||||||
iv: iv,
|
iv: iv,
|
||||||
subsamples: subsamples,
|
subsamples: subsamples,
|
||||||
cryptByteBlock: cryptByteBlock,
|
cryptByteBlock: max(cryptByteBlock, 1),
|
||||||
skipByteBlock: skipByteBlock
|
skipByteBlock: skipByteBlock
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -694,7 +576,6 @@ enum CencMp4Decryptor {
|
||||||
cryptByteBlock: Int,
|
cryptByteBlock: Int,
|
||||||
skipByteBlock: Int
|
skipByteBlock: Int
|
||||||
) -> Data? {
|
) -> Data? {
|
||||||
// Per subsample: IV reset (Chromium/ISO). Pattern chaint binnen de protected range.
|
|
||||||
var out = sample
|
var out = sample
|
||||||
if let subsamples {
|
if let subsamples {
|
||||||
var cursor = 0
|
var cursor = 0
|
||||||
|
|
@ -728,23 +609,17 @@ enum CencMp4Decryptor {
|
||||||
sampleCount: Int,
|
sampleCount: Int,
|
||||||
subsamples: [(clear: Int, protected: Int)]?
|
subsamples: [(clear: Int, protected: Int)]?
|
||||||
) -> [Range<Int>]? {
|
) -> [Range<Int>]? {
|
||||||
guard sampleCount > 0 else { return nil }
|
|
||||||
guard let subsamples else {
|
guard let subsamples else {
|
||||||
return [0..<sampleCount]
|
return [0..<sampleCount]
|
||||||
}
|
}
|
||||||
var ranges: [Range<Int>] = []
|
var ranges: [Range<Int>] = []
|
||||||
var cursor = 0
|
var cursor = 0
|
||||||
for sub in subsamples {
|
for sub in subsamples {
|
||||||
cursor += max(0, sub.clear)
|
cursor += sub.clear
|
||||||
let prot = max(0, sub.protected)
|
if sub.protected > 0 {
|
||||||
if prot > 0 {
|
ranges.append(cursor..<(cursor + sub.protected))
|
||||||
let end = min(cursor + prot, sampleCount)
|
|
||||||
if cursor < end {
|
|
||||||
ranges.append(cursor..<end)
|
|
||||||
}
|
}
|
||||||
}
|
cursor += sub.protected
|
||||||
cursor += prot
|
|
||||||
if cursor >= sampleCount { break }
|
|
||||||
}
|
}
|
||||||
return ranges
|
return ranges
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,17 +30,14 @@ final class ClearKeyDashProxy {
|
||||||
}
|
}
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "nl.vonas.vmc.clearkey-proxy")
|
private let queue = DispatchQueue(label: "nl.vonas.vmc.clearkey-proxy")
|
||||||
/// Beschermt alle gedeelde session-state. Recursive: mediaPlaylist → resolveTemplateURL
|
/// Beschermt alle gedeelde session-state. payload() draait concurrent via Task{} —
|
||||||
/// → withManifestQuery mag genest onder withState draaien (anders deadlock = app hangt).
|
/// zonder deze lock → data races / EXC_BAD_ACCESS.
|
||||||
private let stateLock = NSRecursiveLock()
|
private let stateLock = NSLock()
|
||||||
private let cryptoLock = NSLock()
|
private let cryptoLock = NSLock()
|
||||||
private var listener: NWListener?
|
private var listener: NWListener?
|
||||||
private var port: UInt16 = 0
|
private var port: UInt16 = 0
|
||||||
private var remoteManifest: URL?
|
private var remoteManifest: URL?
|
||||||
/// Eind-URL na redirects (Odido: lb → edge-sessiepad). Segmenten resolven hiertegen en
|
/// Auth-query van het manifest (bijv. Akamai hdnts) — plakken op relatieve segment-URLs.
|
||||||
/// refreshes blijven op deze URL, anders wisselt het sessiepad per refresh (403 / seq-breuk).
|
|
||||||
private var resolvedManifest: URL?
|
|
||||||
/// Auth-query van het manifest (bijv. Akamai `hdnts=…`) — plakken op relatieve segment-URLs.
|
|
||||||
private var manifestQuery: String?
|
private var manifestQuery: String?
|
||||||
private var keyMap: ClearKeyKeyMap?
|
private var keyMap: ClearKeyKeyMap?
|
||||||
private var cryptoVideo = CencMp4Decryptor.SessionState()
|
private var cryptoVideo = CencMp4Decryptor.SessionState()
|
||||||
|
|
@ -57,17 +54,12 @@ final class ClearKeyDashProxy {
|
||||||
/// Stabiele MEDIA-SEQUENCE per segment-URL (voorkomt AVPlayer -12312).
|
/// Stabiele MEDIA-SEQUENCE per segment-URL (voorkomt AVPlayer -12312).
|
||||||
private var segmentSeqByKey: [String: Int] = [:]
|
private var segmentSeqByKey: [String: Int] = [:]
|
||||||
private var nextStableSeq = 1
|
private var nextStableSeq = 1
|
||||||
/// Laatste niet-lege media-playlist per pad (lege refresh → -12312 / [].)
|
/// Laatste niet-lege media-playlist per pad (lege refresh → -12312).
|
||||||
private var lastMediaPlaylistByPath: [String: String] = [:]
|
private var lastMediaPlaylistByPath: [String: String] = [:]
|
||||||
/// Gedeelde MPD-snapshot zodat /hls/v.m3u8 en /hls/a.m3u8 dezelfde live-edge zien
|
/// Gedeelde MPD-snapshot zodat /hls/v.m3u8 en /hls/a.m3u8 dezelfde live-edge zien.
|
||||||
/// (anders A/V MEDIA-SEQUENCE-desync → stall na ~8–10s).
|
|
||||||
/// Som van EXTINF bij VOD/ENDLIST — VLC live-pad meldt anders duur 0 / niet-zoekbaar.
|
|
||||||
private var lastVodDurationMs: Int64 = 0
|
|
||||||
/// VLC kan HLS-cbcs via de proxy niet zoeken; we knippen de playlist vanaf dit punt.
|
|
||||||
private var playbackStartOffsetSec: Double = 0
|
|
||||||
private var cachedMpdXML: String?
|
private var cachedMpdXML: String?
|
||||||
private var cachedMpdAt: Date?
|
private var cachedMpdAt: Date?
|
||||||
private let mpdCacheTTL: TimeInterval = 0.8
|
private let mpdCacheTTL: TimeInterval = 1.0
|
||||||
private let urlSession: URLSession = {
|
private let urlSession: URLSession = {
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
let cfg = URLSessionConfiguration.ephemeral
|
||||||
cfg.timeoutIntervalForRequest = 15
|
cfg.timeoutIntervalForRequest = 15
|
||||||
|
|
@ -78,7 +70,6 @@ final class ClearKeyDashProxy {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// Serialiseert alleen manifest/playlist-requests (niet segment-decrypt).
|
/// Serialiseert alleen manifest/playlist-requests (niet segment-decrypt).
|
||||||
/// Voorheen alles via één gate → segmenten blokkeerden live-refresh → stall na ~10s + zware buffer.
|
|
||||||
private let playlistGate = PayloadGate()
|
private let playlistGate = PayloadGate()
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
@ -104,7 +95,6 @@ final class ClearKeyDashProxy {
|
||||||
let name: String
|
let name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audio-sporen uit de huidige DASH-sessie (voor UI; niet via HLS multi-rendition).
|
|
||||||
func availableAudioTracks() -> [ProxyAudioTrack] {
|
func availableAudioTracks() -> [ProxyAudioTrack] {
|
||||||
withState {
|
withState {
|
||||||
dashAudioReps.enumerated().map { i, a in
|
dashAudioReps.enumerated().map { i, a in
|
||||||
|
|
@ -117,7 +107,6 @@ final class ClearKeyDashProxy {
|
||||||
withState { dashAudioRep?.id }
|
withState { dashAudioRep?.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wissel actief audio-spoor. Caller herlaadt AVPlayer-item (geen HLS rendition-switch).
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func setPreferredAudio(id: String) -> Bool {
|
func setPreferredAudio(id: String) -> Bool {
|
||||||
withState {
|
withState {
|
||||||
|
|
@ -127,7 +116,6 @@ final class ClearKeyDashProxy {
|
||||||
cryptoLock.lock()
|
cryptoLock.lock()
|
||||||
cryptoAudio = CencMp4Decryptor.SessionState()
|
cryptoAudio = CencMp4Decryptor.SessionState()
|
||||||
cryptoLock.unlock()
|
cryptoLock.unlock()
|
||||||
// Alleen audio-playlist-cache legen — video MEDIA-SEQUENCE niet resetten (crash/stotter).
|
|
||||||
lastMediaPlaylistByPath["/hls/a.m3u8"] = nil
|
lastMediaPlaylistByPath["/hls/a.m3u8"] = nil
|
||||||
proxyLog.info("preferred audio → \(match.id, privacy: .public)")
|
proxyLog.info("preferred audio → \(match.id, privacy: .public)")
|
||||||
return true
|
return true
|
||||||
|
|
@ -138,18 +126,6 @@ final class ClearKeyDashProxy {
|
||||||
struct StartResult {
|
struct StartResult {
|
||||||
let url: URL
|
let url: URL
|
||||||
let session: Int
|
let session: Int
|
||||||
let vodDurationMs: Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func vodDurationMs() -> Int64 {
|
|
||||||
withState { lastVodDurationMs }
|
|
||||||
}
|
|
||||||
|
|
||||||
func setPlaybackStartOffset(ms: Int64) {
|
|
||||||
withState {
|
|
||||||
playbackStartOffsetSec = Double(max(0, ms)) / 1000.0
|
|
||||||
lastMediaPlaylistByPath = [:]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD- of m3u8-URL terug.
|
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD- of m3u8-URL terug.
|
||||||
|
|
@ -167,10 +143,8 @@ final class ClearKeyDashProxy {
|
||||||
|
|
||||||
let bound: (port: UInt16, session: Int) = try await withCheckedThrowingContinuation { cont in
|
let bound: (port: UInt16, session: Int) = try await withCheckedThrowingContinuation { cont in
|
||||||
queue.async {
|
queue.async {
|
||||||
self.withState {
|
|
||||||
self.stopLocked()
|
self.stopLocked()
|
||||||
self.remoteManifest = manifestURL
|
self.remoteManifest = manifestURL
|
||||||
self.resolvedManifest = nil
|
|
||||||
self.manifestQuery = URLComponents(url: manifestURL, resolvingAgainstBaseURL: false)?.percentEncodedQuery
|
self.manifestQuery = URLComponents(url: manifestURL, resolvingAgainstBaseURL: false)?.percentEncodedQuery
|
||||||
self.keyMap = map
|
self.keyMap = map
|
||||||
self.isHlsMode = hls
|
self.isHlsMode = hls
|
||||||
|
|
@ -181,19 +155,15 @@ final class ClearKeyDashProxy {
|
||||||
self.segmentSeqByKey = [:]
|
self.segmentSeqByKey = [:]
|
||||||
self.nextStableSeq = 1
|
self.nextStableSeq = 1
|
||||||
self.lastMediaPlaylistByPath = [:]
|
self.lastMediaPlaylistByPath = [:]
|
||||||
self.lastVodDurationMs = 0
|
|
||||||
self.playbackStartOffsetSec = 0
|
|
||||||
self.cachedMpdXML = nil
|
self.cachedMpdXML = nil
|
||||||
self.cachedMpdAt = nil
|
self.cachedMpdAt = nil
|
||||||
self.cryptoLock.lock()
|
self.cryptoLock.lock()
|
||||||
self.cryptoVideo = CencMp4Decryptor.SessionState()
|
self.cryptoVideo = CencMp4Decryptor.SessionState()
|
||||||
self.cryptoAudio = CencMp4Decryptor.SessionState()
|
self.cryptoAudio = CencMp4Decryptor.SessionState()
|
||||||
self.cryptoShared = CencMp4Decryptor.SessionState()
|
self.cryptoShared = CencMp4Decryptor.SessionState()
|
||||||
CencMp4Decryptor.resetDiag()
|
|
||||||
self.cryptoLock.unlock()
|
self.cryptoLock.unlock()
|
||||||
self.generation += 1
|
self.generation += 1
|
||||||
}
|
let session = self.generation
|
||||||
let session = self.withState { self.generation }
|
|
||||||
|
|
||||||
let once = ResumeOnce()
|
let once = ResumeOnce()
|
||||||
|
|
||||||
|
|
@ -201,14 +171,14 @@ final class ClearKeyDashProxy {
|
||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
params.allowLocalEndpointReuse = true
|
params.allowLocalEndpointReuse = true
|
||||||
let listener = try NWListener(using: params, on: .any)
|
let listener = try NWListener(using: params, on: .any)
|
||||||
self.withState { self.listener = listener }
|
self.listener = listener
|
||||||
|
|
||||||
listener.stateUpdateHandler = { [weak self] state in
|
listener.stateUpdateHandler = { [weak self] state in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
switch state {
|
switch state {
|
||||||
case .ready:
|
case .ready:
|
||||||
let p = listener.port?.rawValue ?? 0
|
let p = listener.port?.rawValue ?? 0
|
||||||
self.withState { self.port = p }
|
self.port = p
|
||||||
proxyLog.info("listener ready on port \(p, privacy: .public) session=\(session, privacy: .public)")
|
proxyLog.info("listener ready on port \(p, privacy: .public) session=\(session, privacy: .public)")
|
||||||
once.run {
|
once.run {
|
||||||
if p > 0 {
|
if p > 0 {
|
||||||
|
|
@ -257,10 +227,6 @@ final class ClearKeyDashProxy {
|
||||||
"warmup \(kind, privacy: .public) ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)"
|
"warmup \(kind, privacy: .public) ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if hls || preferHlsForAVPlayer {
|
|
||||||
await peekVodDuration(fromMaster: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefetch init segments zodat CENC track-crypto klaar is vóór AVPlayer media vraagt.
|
// Prefetch init segments zodat CENC track-crypto klaar is vóór AVPlayer media vraagt.
|
||||||
if preferHlsForAVPlayer {
|
if preferHlsForAVPlayer {
|
||||||
await prefetchInitsForAVPlayer()
|
await prefetchInitsForAVPlayer()
|
||||||
|
|
@ -269,37 +235,7 @@ final class ClearKeyDashProxy {
|
||||||
guard let local = URL(string: "http://127.0.0.1:\(bound.port)\(entryPath)") else {
|
guard let local = URL(string: "http://127.0.0.1:\(bound.port)\(entryPath)") else {
|
||||||
throw ProxyError.bindFailed
|
throw ProxyError.bindFailed
|
||||||
}
|
}
|
||||||
return StartResult(url: local, session: gen, vodDurationMs: withState { lastVodDurationMs })
|
return StartResult(url: local, session: gen)
|
||||||
}
|
|
||||||
|
|
||||||
/// Haal één media-playlist op zodat we ENDLIST/duur kennen vóór VLC start.
|
|
||||||
private func peekVodDuration(fromMaster data: Data) async {
|
|
||||||
let text = String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self)
|
|
||||||
var videoPath: String?
|
|
||||||
var audioPath: String?
|
|
||||||
for raw in text.components(separatedBy: .newlines) {
|
|
||||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
|
||||||
let uri: String?
|
|
||||||
if line.hasPrefix("#"), let q = Self.hlsMediaAttr(line, "URI") {
|
|
||||||
uri = q
|
|
||||||
} else if line.hasPrefix("http://127.0.0.1:") {
|
|
||||||
uri = line
|
|
||||||
} else {
|
|
||||||
uri = nil
|
|
||||||
}
|
|
||||||
guard let uri, let url = URL(string: uri) else { continue }
|
|
||||||
let path = url.path + (url.query.map { "?\($0)" } ?? "")
|
|
||||||
guard path.hasPrefix("/a/") else { continue }
|
|
||||||
if path.contains("ck=v") { videoPath = path }
|
|
||||||
else if path.contains("ck=a") { audioPath = path }
|
|
||||||
else if videoPath == nil { videoPath = path }
|
|
||||||
}
|
|
||||||
guard let path = videoPath ?? audioPath else { return }
|
|
||||||
_ = try? await payload(for: path)
|
|
||||||
let ms = withState { lastVodDurationMs }
|
|
||||||
if ms > 0 {
|
|
||||||
proxyLog.info("vod duration \(ms, privacy: .public)ms from peek")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func prefetchInitsForAVPlayer() async {
|
private func prefetchInitsForAVPlayer() async {
|
||||||
|
|
@ -390,7 +326,6 @@ final class ClearKeyDashProxy {
|
||||||
listener = nil
|
listener = nil
|
||||||
port = 0
|
port = 0
|
||||||
remoteManifest = nil
|
remoteManifest = nil
|
||||||
resolvedManifest = nil
|
|
||||||
manifestQuery = nil
|
manifestQuery = nil
|
||||||
keyMap = nil
|
keyMap = nil
|
||||||
isHlsMode = false
|
isHlsMode = false
|
||||||
|
|
@ -401,8 +336,6 @@ final class ClearKeyDashProxy {
|
||||||
segmentSeqByKey = [:]
|
segmentSeqByKey = [:]
|
||||||
nextStableSeq = 1
|
nextStableSeq = 1
|
||||||
lastMediaPlaylistByPath = [:]
|
lastMediaPlaylistByPath = [:]
|
||||||
lastVodDurationMs = 0
|
|
||||||
playbackStartOffsetSec = 0
|
|
||||||
cachedMpdXML = nil
|
cachedMpdXML = nil
|
||||||
cachedMpdAt = nil
|
cachedMpdAt = nil
|
||||||
cryptoLock.lock()
|
cryptoLock.lock()
|
||||||
|
|
@ -556,13 +489,13 @@ final class ClearKeyDashProxy {
|
||||||
let query = split.count > 1 ? split[1] : nil
|
let query = split.count > 1 ? split[1] : nil
|
||||||
|
|
||||||
if pathOnly == "/" || pathOnly == "/manifest.mpd" || pathOnly == "/manifest.m3u8" {
|
if pathOnly == "/" || pathOnly == "/manifest.mpd" || pathOnly == "/manifest.m3u8" {
|
||||||
let (raw, manifestBase) = try await fetchManifest(remoteManifest)
|
let raw = try await fetch(remoteManifest)
|
||||||
if isHlsMode || Self.looksLikeHls(raw) {
|
if isHlsMode || Self.looksLikeHls(raw) {
|
||||||
let text = String(data: raw, encoding: .utf8) ?? String(decoding: raw, as: UTF8.self)
|
let text = String(data: raw, encoding: .utf8) ?? String(decoding: raw, as: UTF8.self)
|
||||||
guard text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("#EXTM3U") else {
|
guard text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("#EXTM3U") else {
|
||||||
throw ProxyError.emptyManifest
|
throw ProxyError.emptyManifest
|
||||||
}
|
}
|
||||||
let rewritten = rewriteHls(text, playlistURL: manifestBase)
|
let rewritten = rewriteHls(text, playlistURL: remoteManifest)
|
||||||
return (Data(rewritten.utf8), "application/vnd.apple.mpegurl")
|
return (Data(rewritten.utf8), "application/vnd.apple.mpegurl")
|
||||||
}
|
}
|
||||||
let xml: String = {
|
let xml: String = {
|
||||||
|
|
@ -574,10 +507,10 @@ final class ClearKeyDashProxy {
|
||||||
}
|
}
|
||||||
// AVPlayer: DASH → HLS master (+ media playlists via /hls/*).
|
// AVPlayer: DASH → HLS master (+ media playlists via /hls/*).
|
||||||
if preferHlsOutput || pathOnly == "/manifest.m3u8" {
|
if preferHlsOutput || pathOnly == "/manifest.m3u8" {
|
||||||
let hls = try withState { try dashToHlsMaster(xml, mpdURL: manifestBase) }
|
let hls = try withState { try dashToHlsMaster(xml, mpdURL: remoteManifest) }
|
||||||
return (Data(hls.utf8), "application/vnd.apple.mpegurl")
|
return (Data(hls.utf8), "application/vnd.apple.mpegurl")
|
||||||
}
|
}
|
||||||
let rewritten = rewriteMpd(xml, mpdURL: manifestBase)
|
let rewritten = rewriteMpd(xml, mpdURL: remoteManifest)
|
||||||
if !rewritten.localizedCaseInsensitiveContains("<MPD") {
|
if !rewritten.localizedCaseInsensitiveContains("<MPD") {
|
||||||
throw ProxyError.emptyManifest
|
throw ProxyError.emptyManifest
|
||||||
}
|
}
|
||||||
|
|
@ -668,7 +601,7 @@ final class ClearKeyDashProxy {
|
||||||
let lower = url.absoluteString.lowercased()
|
let lower = url.absoluteString.lowercased()
|
||||||
if Self.isHlsURL(url) || Self.looksLikeHls(data) {
|
if Self.isHlsURL(url) || Self.looksLikeHls(data) {
|
||||||
let text = String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self)
|
let text = String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self)
|
||||||
let rewritten = rewriteHls(text, playlistURL: url, laneHint: lane)
|
let rewritten = rewriteHls(text, playlistURL: url)
|
||||||
return (Data(rewritten.utf8), "application/vnd.apple.mpegurl")
|
return (Data(rewritten.utf8), "application/vnd.apple.mpegurl")
|
||||||
}
|
}
|
||||||
let isManifest = lower.contains(".mpd")
|
let isManifest = lower.contains(".mpd")
|
||||||
|
|
@ -678,10 +611,6 @@ final class ClearKeyDashProxy {
|
||||||
}
|
}
|
||||||
cryptoLock.lock()
|
cryptoLock.lock()
|
||||||
defer { cryptoLock.unlock() }
|
defer { cryptoLock.unlock() }
|
||||||
if CencMp4Decryptor.diagSink == nil {
|
|
||||||
CencMp4Decryptor.diagSink = { proxyLog.info("\($0, privacy: .public)") }
|
|
||||||
}
|
|
||||||
CencMp4Decryptor.diagTag = lane.rawValue
|
|
||||||
switch lane {
|
switch lane {
|
||||||
case .video:
|
case .video:
|
||||||
_ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoVideo)
|
_ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoVideo)
|
||||||
|
|
@ -703,7 +632,7 @@ final class ClearKeyDashProxy {
|
||||||
|
|
||||||
/// Strip Widevine/PlayReady KEY-tags; herschrijf media-URI's naar de proxy.
|
/// Strip Widevine/PlayReady KEY-tags; herschrijf media-URI's naar de proxy.
|
||||||
/// ESPN EVENT-playlists met duizenden segmenten → trim naar live-edge (anders audio-only / geen beeld).
|
/// ESPN EVENT-playlists met duizenden segmenten → trim naar live-edge (anders audio-only / geen beeld).
|
||||||
private func rewriteHls(_ text: String, playlistURL: URL, laneHint: CryptoLane = .shared) -> String {
|
private func rewriteHls(_ text: String, playlistURL: URL) -> String {
|
||||||
let lines = text.components(separatedBy: .newlines)
|
let lines = text.components(separatedBy: .newlines)
|
||||||
let isMaster = lines.contains { $0.hasPrefix("#EXT-X-STREAM-INF") }
|
let isMaster = lines.contains { $0.hasPrefix("#EXT-X-STREAM-INF") }
|
||||||
var out: [String] = []
|
var out: [String] = []
|
||||||
|
|
@ -715,10 +644,7 @@ final class ClearKeyDashProxy {
|
||||||
|
|
||||||
// Gescheiden crypto-lanes: CMAF audio+video delen vaak track_ID=1.
|
// Gescheiden crypto-lanes: CMAF audio+video delen vaak track_ID=1.
|
||||||
// Shared state → audio-init overschrijft video → geluid zonder beeld (ESPN).
|
// Shared state → audio-init overschrijft video → geluid zonder beeld (ESPN).
|
||||||
// De master kent TYPE=AUDIO al toe (ck=a); de URL-heuristiek mist o.a. F1TV-audio.
|
let lane = Self.hlsMediaLane(for: playlistURL)
|
||||||
let guessed = Self.hlsMediaLane(for: playlistURL)
|
|
||||||
let lane = laneHint == .shared ? guessed : laneHint
|
|
||||||
proxyLog.info("media playlist lane=\(lane.rawValue, privacy: .public) hint=\(laneHint.rawValue, privacy: .public) guess=\(guessed.rawValue, privacy: .public) file=\(playlistURL.lastPathComponent, privacy: .public)")
|
|
||||||
|
|
||||||
// Media playlist: verzamel tags + segmenten, trim live-edge.
|
// Media playlist: verzamel tags + segmenten, trim live-edge.
|
||||||
var header: [String] = []
|
var header: [String] = []
|
||||||
|
|
@ -728,7 +654,6 @@ final class ClearKeyDashProxy {
|
||||||
var mediaSequence: Int?
|
var mediaSequence: Int?
|
||||||
var targetDuration: Int = 6
|
var targetDuration: Int = 6
|
||||||
var hasEndList = false
|
var hasEndList = false
|
||||||
var playlistType: String?
|
|
||||||
|
|
||||||
for line in lines {
|
for line in lines {
|
||||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
@ -736,8 +661,7 @@ final class ClearKeyDashProxy {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if trimmed.hasPrefix("#EXT-X-PLAYLIST-TYPE:") {
|
if trimmed.hasPrefix("#EXT-X-PLAYLIST-TYPE:") {
|
||||||
playlistType = trimmed.split(separator: ":").last
|
// EVENT met uren history → AVPlayer start verkeerd / audio-only. Behandel als live.
|
||||||
.map { String($0).trimmingCharacters(in: .whitespaces).uppercased() }
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if trimmed.hasPrefix("#EXT-X-MEDIA-SEQUENCE:") {
|
if trimmed.hasPrefix("#EXT-X-MEDIA-SEQUENCE:") {
|
||||||
|
|
@ -783,46 +707,27 @@ final class ClearKeyDashProxy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live-edge alleen voor echte live (ESPN). F1-replays / VOD hebben ENDLIST of
|
// Live-edge window: ~12–16 segmenten (±60–90s bij 5–6s ESPN, ±40–60s bij 3.8s).
|
||||||
// een lange statische lijst: trim + START=-12 + geen ENDLIST → ~12s beeld, dan vast.
|
|
||||||
let host = (playlistURL.host ?? "").lowercased()
|
|
||||||
let isComplete = hasEndList || playlistType == "VOD"
|
|
||||||
let isF1 = host.contains("formula1.com")
|
|
||||||
let keep = 14
|
let keep = 14
|
||||||
let startIdx: Int
|
let startIdx: Int
|
||||||
let seqBase = mediaSequence ?? 0
|
let seqBase = mediaSequence ?? 0
|
||||||
if isComplete || isF1 {
|
if !hasEndList, segments.count > keep {
|
||||||
startIdx = 0
|
startIdx = segments.count - keep
|
||||||
} else if segments.count > keep {
|
} else if segments.count > 40 {
|
||||||
|
// VOD/EVENT-achtig met enorme history: toch trimmen naar einde.
|
||||||
startIdx = segments.count - keep
|
startIdx = segments.count - keep
|
||||||
} else {
|
} else {
|
||||||
startIdx = 0
|
startIdx = 0
|
||||||
}
|
}
|
||||||
let window = Array(segments.suffix(from: startIdx))
|
let window = Array(segments.suffix(from: startIdx))
|
||||||
if isComplete || (isF1 && startIdx == 0 && window.count > 40) {
|
let seq = seqBase + startIdx
|
||||||
let ms = Self.playlistDurationMs(window.map(\.inf), fallbackSeconds: targetDuration)
|
|
||||||
withState {
|
|
||||||
if ms > self.lastVodDurationMs { self.lastVodDurationMs = ms }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let seekSec = withState { self.playbackStartOffsetSec }
|
|
||||||
var playFrom = 0
|
|
||||||
if seekSec > 0.4, isComplete || isF1 {
|
|
||||||
playFrom = Self.segmentIndex(at: seekSec, infLines: window.map(\.inf), fallbackSeconds: targetDuration)
|
|
||||||
}
|
|
||||||
let playWindow = Array(window.suffix(from: playFrom))
|
|
||||||
let seq = seqBase + startIdx + playFrom
|
|
||||||
proxyLog.info("hls rewrite segs=\(segments.count, privacy: .public) keep=\(playWindow.count, privacy: .public) endlist=\(hasEndList, privacy: .public) type=\(playlistType ?? "-", privacy: .public) f1=\(isF1, privacy: .public) startIdx=\(startIdx, privacy: .public) seek=\(Int(seekSec), privacy: .public)s vodMs=\(self.lastVodDurationMs, privacy: .public)")
|
|
||||||
|
|
||||||
out.append("#EXTM3U")
|
out.append("#EXTM3U")
|
||||||
out.append("#EXT-X-VERSION:7")
|
out.append("#EXT-X-VERSION:7")
|
||||||
out.append("#EXT-X-TARGETDURATION:\(targetDuration)")
|
out.append("#EXT-X-TARGETDURATION:\(targetDuration)")
|
||||||
out.append("#EXT-X-MEDIA-SEQUENCE:\(seq)")
|
out.append("#EXT-X-MEDIA-SEQUENCE:\(seq)")
|
||||||
out.append("#EXT-X-INDEPENDENT-SEGMENTS")
|
out.append("#EXT-X-INDEPENDENT-SEGMENTS")
|
||||||
if isComplete {
|
// Live-achtig: start dicht bij einde.
|
||||||
out.append("#EXT-X-PLAYLIST-TYPE:VOD")
|
|
||||||
}
|
|
||||||
// Live-achtig: start dicht bij einde. Nooit op een complete/F1-replay.
|
|
||||||
if startIdx > 0 {
|
if startIdx > 0 {
|
||||||
out.append("#EXT-X-START:TIME-OFFSET=-12.0,PRECISE=YES")
|
out.append("#EXT-X-START:TIME-OFFSET=-12.0,PRECISE=YES")
|
||||||
}
|
}
|
||||||
|
|
@ -833,45 +738,17 @@ final class ClearKeyDashProxy {
|
||||||
out.append(h)
|
out.append(h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for seg in playWindow {
|
for seg in window {
|
||||||
out.append(seg.inf)
|
out.append(seg.inf)
|
||||||
let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: seg.uri)
|
let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: seg.uri)
|
||||||
out.append(proxyAbsoluteURL(resolved, lane: lane))
|
out.append(proxyAbsoluteURL(resolved, lane: lane))
|
||||||
}
|
}
|
||||||
if isComplete {
|
if hasEndList && startIdx == 0 {
|
||||||
out.append("#EXT-X-ENDLIST")
|
out.append("#EXT-X-ENDLIST")
|
||||||
}
|
}
|
||||||
return out.joined(separator: "\n")
|
return out.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func playlistDurationMs(_ infLines: [String], fallbackSeconds: Int) -> Int64 {
|
|
||||||
var total: Double = 0
|
|
||||||
for inf in infLines {
|
|
||||||
total += extinfSeconds(inf, fallback: fallbackSeconds)
|
|
||||||
}
|
|
||||||
return Int64(total * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func segmentIndex(at seconds: Double, infLines: [String], fallbackSeconds: Int) -> Int {
|
|
||||||
guard !infLines.isEmpty else { return 0 }
|
|
||||||
var acc = 0.0
|
|
||||||
for (i, inf) in infLines.enumerated() {
|
|
||||||
acc += extinfSeconds(inf, fallback: fallbackSeconds)
|
|
||||||
if acc >= seconds { return i }
|
|
||||||
}
|
|
||||||
return max(0, infLines.count - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func extinfSeconds(_ inf: String, fallback: Int) -> Double {
|
|
||||||
let raw = inf.trimmingCharacters(in: .whitespaces)
|
|
||||||
let rest = raw.hasPrefix("#EXTINF:") ? String(raw.dropFirst(8)) : raw
|
|
||||||
let token = rest.split(separator: ",", maxSplits: 1).first.map(String.init) ?? ""
|
|
||||||
if let seconds = Double(token.trimmingCharacters(in: .whitespaces)) {
|
|
||||||
return seconds
|
|
||||||
}
|
|
||||||
return Double(max(fallback, 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Audio- vs video-playlist herkennen (ESPN: `audio-aac-…` vs `cmaf-cenc-…`).
|
/// Audio- vs video-playlist herkennen (ESPN: `audio-aac-…` vs `cmaf-cenc-…`).
|
||||||
private static func hlsMediaLane(for playlistURL: URL) -> CryptoLane {
|
private static func hlsMediaLane(for playlistURL: URL) -> CryptoLane {
|
||||||
let s = playlistURL.path.lowercased()
|
let s = playlistURL.path.lowercased()
|
||||||
|
|
@ -886,7 +763,6 @@ final class ClearKeyDashProxy {
|
||||||
/// Master: strip DRM keys, proxy alle variant/audio URI's — audio→ck=a, video→ck=v.
|
/// Master: strip DRM keys, proxy alle variant/audio URI's — audio→ck=a, video→ck=v.
|
||||||
private func rewriteHlsMaster(_ lines: [String], playlistURL: URL) -> String {
|
private func rewriteHlsMaster(_ lines: [String], playlistURL: URL) -> String {
|
||||||
var out: [String] = []
|
var out: [String] = []
|
||||||
var audioLines: [String] = []
|
|
||||||
var i = 0
|
var i = 0
|
||||||
while i < lines.count {
|
while i < lines.count {
|
||||||
let line = lines[i]
|
let line = lines[i]
|
||||||
|
|
@ -913,12 +789,7 @@ final class ClearKeyDashProxy {
|
||||||
}
|
}
|
||||||
if trimmed.hasPrefix("#EXT-X-MEDIA:") {
|
if trimmed.hasPrefix("#EXT-X-MEDIA:") {
|
||||||
let lane: CryptoLane = trimmed.contains("TYPE=AUDIO") ? .audio : .shared
|
let lane: CryptoLane = trimmed.contains("TYPE=AUDIO") ? .audio : .shared
|
||||||
let rewritten = rewriteHlsTagURIs(line, base: playlistURL, lane: lane)
|
out.append(rewriteHlsTagURIs(line, base: playlistURL, lane: lane))
|
||||||
if lane == .audio {
|
|
||||||
audioLines.append(rewritten)
|
|
||||||
} else {
|
|
||||||
out.append(rewritten)
|
|
||||||
}
|
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -932,78 +803,9 @@ final class ClearKeyDashProxy {
|
||||||
}
|
}
|
||||||
i += 1
|
i += 1
|
||||||
}
|
}
|
||||||
if !audioLines.isEmpty {
|
|
||||||
let ranked = Self.rankHlsAudioLines(audioLines)
|
|
||||||
if let idx = out.firstIndex(where: { $0.hasPrefix("#EXT-X-STREAM-INF") }) {
|
|
||||||
out.insert(contentsOf: ranked, at: idx)
|
|
||||||
} else {
|
|
||||||
out.append(contentsOf: ranked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out.joined(separator: "\n")
|
return out.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NL → Engels → rest. Winnaar krijgt DEFAULT=YES zodat VLC niet op Spaans start.
|
|
||||||
private static func rankHlsAudioLines(_ lines: [String]) -> [String] {
|
|
||||||
let scored = lines.enumerated().map { i, line in (line, hlsAudioScore(line) * 100 - i) }
|
|
||||||
let sorted = scored.sorted { $0.1 > $1.1 }.map(\.0)
|
|
||||||
guard let first = sorted.first else { return lines }
|
|
||||||
var out: [String] = []
|
|
||||||
for (i, line) in sorted.enumerated() {
|
|
||||||
out.append(hlsSetAttr(hlsSetAttr(line, key: "DEFAULT", value: i == 0 ? "YES" : "NO"), key: "AUTOSELECT", value: "YES"))
|
|
||||||
}
|
|
||||||
let lang = hlsMediaAttr(first, "LANGUAGE") ?? "-"
|
|
||||||
let name = hlsMediaAttr(first, "NAME") ?? "-"
|
|
||||||
proxyLog.info("hls audio default lang=\(lang, privacy: .public) name=\(name, privacy: .public) count=\(sorted.count, privacy: .public)")
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hlsAudioScore(_ line: String) -> Int {
|
|
||||||
let lang = (hlsMediaAttr(line, "LANGUAGE") ?? "").lowercased()
|
|
||||||
let name = (hlsMediaAttr(line, "NAME") ?? "").lowercased()
|
|
||||||
if ["nl", "dut", "nld"].contains(lang) || name.contains("nederlands") || name.contains("dutch") {
|
|
||||||
return 100
|
|
||||||
}
|
|
||||||
if ["en", "eng"].contains(lang) || name.contains("english") || name.contains("engels") {
|
|
||||||
return 50
|
|
||||||
}
|
|
||||||
if name.contains("international") { return 35 }
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hlsMediaAttr(_ line: String, _ key: String) -> String? {
|
|
||||||
let pattern = #"\#(key)=(?:"([^"]*)"|([^,]*))"#
|
|
||||||
guard let re = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return nil }
|
|
||||||
let ns = line as NSString
|
|
||||||
guard let m = re.firstMatch(in: line, options: [], range: NSRange(location: 0, length: ns.length)) else { return nil }
|
|
||||||
if m.numberOfRanges > 1, m.range(at: 1).location != NSNotFound {
|
|
||||||
let v = ns.substring(with: m.range(at: 1)).trimmingCharacters(in: .whitespaces)
|
|
||||||
return v.isEmpty ? nil : v
|
|
||||||
}
|
|
||||||
if m.numberOfRanges > 2, m.range(at: 2).location != NSNotFound {
|
|
||||||
let v = ns.substring(with: m.range(at: 2)).trimmingCharacters(in: .whitespaces)
|
|
||||||
return v.isEmpty ? nil : v
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hlsSetAttr(_ line: String, key: String, value: String) -> String {
|
|
||||||
let pattern = #"\#(key)=(?:"[^"]*"|[^,]*)"#
|
|
||||||
let replacement = "\(key)=\"\(value)\""
|
|
||||||
guard let re = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else {
|
|
||||||
return line + ",\(replacement)"
|
|
||||||
}
|
|
||||||
let ns = line as NSString
|
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
|
||||||
if re.firstMatch(in: line, options: [], range: range) != nil {
|
|
||||||
return re.stringByReplacingMatches(in: line, options: [], range: range, withTemplate: replacement)
|
|
||||||
}
|
|
||||||
if line.hasPrefix("#EXT-X-MEDIA:") {
|
|
||||||
return "#EXT-X-MEDIA:\(replacement)," + String(line.dropFirst("#EXT-X-MEDIA:".count))
|
|
||||||
}
|
|
||||||
return line + ",\(replacement)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rewriteHlsTagURIs(_ line: String, base: URL, lane: CryptoLane = .shared) -> String {
|
private func rewriteHlsTagURIs(_ line: String, base: URL, lane: CryptoLane = .shared) -> String {
|
||||||
guard let regex = try? NSRegularExpression(pattern: #"URI="([^"]+)""#, options: []) else {
|
guard let regex = try? NSRegularExpression(pattern: #"URI="([^"]+)""#, options: []) else {
|
||||||
return line
|
return line
|
||||||
|
|
@ -1040,10 +842,6 @@ final class ClearKeyDashProxy {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetch(_ url: URL) async throws -> Data {
|
private func fetch(_ url: URL) async throws -> Data {
|
||||||
try await fetchFollowingRedirects(url).data
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchFollowingRedirects(_ url: URL) async throws -> (data: Data, finalURL: URL) {
|
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.timeoutInterval = 15
|
req.timeoutInterval = 15
|
||||||
req.cachePolicy = .reloadIgnoringLocalCacheData
|
req.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
|
@ -1053,25 +851,7 @@ final class ClearKeyDashProxy {
|
||||||
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||||
throw ProxyError.upstream(http.statusCode)
|
throw ProxyError.upstream(http.statusCode)
|
||||||
}
|
}
|
||||||
return (data, resp.url ?? url)
|
return data
|
||||||
}
|
|
||||||
|
|
||||||
/// Manifest ophalen via de sticky eind-URL (zoals ExoPlayer); faalt die, één keer opnieuw via het origineel.
|
|
||||||
private func fetchManifest(_ original: URL) async throws -> (data: Data, baseURL: URL) {
|
|
||||||
let sticky = withState { resolvedManifest }
|
|
||||||
if let sticky, sticky != original {
|
|
||||||
if let got = try? await fetchFollowingRedirects(sticky) {
|
|
||||||
withState { resolvedManifest = got.finalURL }
|
|
||||||
return (got.data, got.finalURL)
|
|
||||||
}
|
|
||||||
proxyLog.error("sticky manifest failed — re-resolving via original URL")
|
|
||||||
}
|
|
||||||
let got = try await fetchFollowingRedirects(original)
|
|
||||||
withState { resolvedManifest = got.finalURL }
|
|
||||||
if got.finalURL != original {
|
|
||||||
proxyLog.info("manifest redirected to \(got.finalURL.host ?? "?", privacy: .public)")
|
|
||||||
}
|
|
||||||
return (got.data, got.finalURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rewriteMpd(_ xml: String, mpdURL: URL) -> String {
|
private func rewriteMpd(_ xml: String, mpdURL: URL) -> String {
|
||||||
|
|
@ -1101,8 +881,8 @@ final class ClearKeyDashProxy {
|
||||||
let matches = baseRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
|
let matches = baseRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
|
||||||
for match in matches.reversed() {
|
for match in matches.reversed() {
|
||||||
guard match.numberOfRanges >= 2 else { continue }
|
guard match.numberOfRanges >= 2 else { continue }
|
||||||
let content = Self.xmlUnescape(ns.substring(with: match.range(at: 1))
|
let content = ns.substring(with: match.range(at: 1))
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines))
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if content.contains("127.0.0.1:\(port)") {
|
if content.contains("127.0.0.1:\(port)") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -1262,7 +1042,7 @@ final class ClearKeyDashProxy {
|
||||||
private static func preferredAudioOrder(_ audios: [DashHlsRep]) -> [DashHlsRep] {
|
private static func preferredAudioOrder(_ audios: [DashHlsRep]) -> [DashHlsRep] {
|
||||||
let aac = audios.filter(\.isAac)
|
let aac = audios.filter(\.isAac)
|
||||||
let pool = aac.isEmpty ? audios : aac
|
let pool = aac.isEmpty ? audios : aac
|
||||||
let preferred = "nl"
|
let preferred = Locale.current.language.languageCode?.identifier.lowercased() ?? "nl"
|
||||||
return pool.sorted { a, b in
|
return pool.sorted { a, b in
|
||||||
let aScore = Self.audioLocaleScore(a, preferred: preferred)
|
let aScore = Self.audioLocaleScore(a, preferred: preferred)
|
||||||
let bScore = Self.audioLocaleScore(b, preferred: preferred)
|
let bScore = Self.audioLocaleScore(b, preferred: preferred)
|
||||||
|
|
@ -1529,12 +1309,12 @@ final class ClearKeyDashProxy {
|
||||||
let s = String(block[initMatch])
|
let s = String(block[initMatch])
|
||||||
if let q1 = s.range(of: "sourceURL=\"")?.upperBound,
|
if let q1 = s.range(of: "sourceURL=\"")?.upperBound,
|
||||||
let q2 = s[q1...].firstIndex(of: "\"") {
|
let q2 = s[q1...].firstIndex(of: "\"") {
|
||||||
out.initialization = xmlUnescape(String(s[q1..<q2]))
|
out.initialization = String(s[q1..<q2])
|
||||||
}
|
}
|
||||||
} else if let initMatch = block.range(of: #"sourceURL="([^"]+)""#, options: [.regularExpression, .caseInsensitive]) {
|
} else if let initMatch = block.range(of: #"sourceURL="([^"]+)""#, options: [.regularExpression, .caseInsensitive]) {
|
||||||
let s = String(block[initMatch])
|
let s = String(block[initMatch])
|
||||||
if let q1 = s.firstIndex(of: "\""), let q2 = s.lastIndex(of: "\""), q1 < q2 {
|
if let q1 = s.firstIndex(of: "\""), let q2 = s.lastIndex(of: "\""), q1 < q2 {
|
||||||
out.initialization = xmlUnescape(String(s[s.index(after: q1)..<q2]))
|
out.initialization = String(s[s.index(after: q1)..<q2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let urlRegex = try? NSRegularExpression(
|
if let urlRegex = try? NSRegularExpression(
|
||||||
|
|
@ -1544,7 +1324,7 @@ final class ClearKeyDashProxy {
|
||||||
let ns = block as NSString
|
let ns = block as NSString
|
||||||
for m in urlRegex.matches(in: block, options: [], range: NSRange(location: 0, length: ns.length)) {
|
for m in urlRegex.matches(in: block, options: [], range: NSRange(location: 0, length: ns.length)) {
|
||||||
guard m.numberOfRanges >= 2 else { continue }
|
guard m.numberOfRanges >= 2 else { continue }
|
||||||
let media = xmlUnescape(ns.substring(with: m.range(at: 1)))
|
let media = ns.substring(with: m.range(at: 1))
|
||||||
if !media.isEmpty { out.segmentListMedia.append(media) }
|
if !media.isEmpty { out.segmentListMedia.append(media) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1558,18 +1338,7 @@ final class ClearKeyDashProxy {
|
||||||
}
|
}
|
||||||
let block = String(xml[r])
|
let block = String(xml[r])
|
||||||
guard let start = block.firstIndex(of: ">"), let end = block.lastIndex(of: "<"), start < end else { return nil }
|
guard let start = block.firstIndex(of: ">"), let end = block.lastIndex(of: "<"), start < end else { return nil }
|
||||||
return xmlUnescape(String(block[block.index(after: start)..<end]).trimmingCharacters(in: .whitespacesAndNewlines))
|
return String(block[block.index(after: start)..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
|
||||||
|
|
||||||
/// MPD is XML: `&` in BaseURL/template-URLs moet `&` worden, anders weigert de CDN (Odido: 403).
|
|
||||||
private static func xmlUnescape(_ s: String) -> String {
|
|
||||||
guard s.contains("&") else { return s }
|
|
||||||
return s
|
|
||||||
.replacingOccurrences(of: "<", with: "<")
|
|
||||||
.replacingOccurrences(of: ">", with: ">")
|
|
||||||
.replacingOccurrences(of: """, with: "\"")
|
|
||||||
.replacingOccurrences(of: "'", with: "'")
|
|
||||||
.replacingOccurrences(of: "&", with: "&")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func xmlAttr(_ attrs: String, _ name: String) -> String? {
|
private static func xmlAttr(_ attrs: String, _ name: String) -> String? {
|
||||||
|
|
@ -1582,7 +1351,7 @@ final class ClearKeyDashProxy {
|
||||||
let ns = attrs as NSString
|
let ns = attrs as NSString
|
||||||
if let m = re.firstMatch(in: attrs, options: [], range: NSRange(location: 0, length: ns.length)),
|
if let m = re.firstMatch(in: attrs, options: [], range: NSRange(location: 0, length: ns.length)),
|
||||||
m.numberOfRanges >= 2 {
|
m.numberOfRanges >= 2 {
|
||||||
return xmlUnescape(ns.substring(with: m.range(at: 1)))
|
return ns.substring(with: m.range(at: 1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -1801,21 +1570,19 @@ final class ClearKeyDashProxy {
|
||||||
let remote = withState { remoteManifest }
|
let remote = withState { remoteManifest }
|
||||||
guard let remoteManifest = remote else { throw ProxyError.notStarted }
|
guard let remoteManifest = remote else { throw ProxyError.notStarted }
|
||||||
|
|
||||||
let cached: (xml: String, base: URL)? = withState {
|
let cached: String? = withState {
|
||||||
if let xml = cachedMpdXML, let at = cachedMpdAt,
|
if let xml = cachedMpdXML, let at = cachedMpdAt,
|
||||||
Date().timeIntervalSince(at) < mpdCacheTTL {
|
Date().timeIntervalSince(at) < mpdCacheTTL {
|
||||||
return (xml, resolvedManifest ?? remoteManifest)
|
return xml
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let xml: String
|
let xml: String
|
||||||
let manifestBase: URL
|
|
||||||
if let cached {
|
if let cached {
|
||||||
xml = cached.xml
|
xml = cached
|
||||||
manifestBase = cached.base
|
|
||||||
} else {
|
} else {
|
||||||
let (raw, base) = try await fetchManifest(remoteManifest)
|
let raw = try await fetch(remoteManifest)
|
||||||
let fetched: String = {
|
let fetched: String = {
|
||||||
if let s = String(data: raw, encoding: .utf8), !s.isEmpty { return s }
|
if let s = String(data: raw, encoding: .utf8), !s.isEmpty { return s }
|
||||||
return String(decoding: raw, as: UTF8.self)
|
return String(decoding: raw, as: UTF8.self)
|
||||||
|
|
@ -1825,10 +1592,9 @@ final class ClearKeyDashProxy {
|
||||||
cachedMpdAt = Date()
|
cachedMpdAt = Date()
|
||||||
}
|
}
|
||||||
xml = fetched
|
xml = fetched
|
||||||
manifestBase = base
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let reps = parseDashReps(xml, mpdURL: manifestBase)
|
let reps = parseDashReps(xml, mpdURL: remoteManifest)
|
||||||
let videos = reps.filter { !$0.isAudio }.map(\.rep)
|
let videos = reps.filter { !$0.isAudio }.map(\.rep)
|
||||||
let audios = reps.filter(\.isAudio).map(\.rep)
|
let audios = reps.filter(\.isAudio).map(\.rep)
|
||||||
|
|
||||||
|
|
@ -2061,3 +1827,4 @@ final class ClearKeyDashProxy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,34 +22,16 @@ struct ClearKeyKeyMap {
|
||||||
|
|
||||||
var isEmpty: Bool { keysByKid.isEmpty }
|
var isEmpty: Bool { keysByKid.isEmpty }
|
||||||
|
|
||||||
/// Unieke key-waarden in de map (meestal 1).
|
/// Eerste key (streams hebben meestal één kid/key-paar).
|
||||||
var uniqueKeyCount: Int {
|
var primaryKey: Data? { keysByKid.values.first }
|
||||||
Set(keysByKid.values.map { $0.base64EncodedString() }).count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Eerste key — alleen veilig als er precies één unieke key is.
|
|
||||||
var primaryKey: Data? {
|
|
||||||
guard uniqueKeyCount == 1 else { return nil }
|
|
||||||
return keysByKid.values.first
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasExactKey(forKid kid: Data) -> Bool {
|
|
||||||
guard kid.count == 16 else { return false }
|
|
||||||
if keysByKid[kid.map { String(format: "%02x", $0) }.joined()] != nil { return true }
|
|
||||||
let swapped = Self.swapUuidEndianness(kid)
|
|
||||||
return keysByKid[swapped.map { String(format: "%02x", $0) }.joined()] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func key(forKid kid: Data) -> Data? {
|
func key(forKid kid: Data) -> Data? {
|
||||||
if kid.count == 16 {
|
guard kid.count == 16 else { return primaryKey }
|
||||||
let hex = kid.map { String(format: "%02x", $0) }.joined()
|
let hex = kid.map { String(format: "%02x", $0) }.joined()
|
||||||
if let k = keysByKid[hex] { return k }
|
if let k = keysByKid[hex] { return k }
|
||||||
let swapped = Self.swapUuidEndianness(kid)
|
let swapped = Self.swapUuidEndianness(kid)
|
||||||
let swappedHex = swapped.map { String(format: "%02x", $0) }.joined()
|
let swappedHex = swapped.map { String(format: "%02x", $0) }.joined()
|
||||||
if let k = keysByKid[swappedHex] { return k }
|
return keysByKid[swappedHex] ?? primaryKey
|
||||||
}
|
|
||||||
// Streams met één key (Android parity): gebruik die key als KID-match faalt.
|
|
||||||
return primaryKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func sanitizeHex(_ raw: String) -> String {
|
static func sanitizeHex(_ raw: String) -> String {
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@ struct PlayRequest: Identifiable {
|
||||||
let watchSessionId: String?
|
let watchSessionId: String?
|
||||||
let watchKind: String?
|
let watchKind: String?
|
||||||
let watchId: String?
|
let watchId: String?
|
||||||
/// Provider-/titel-logo voor laad-animatie (events, films, series).
|
|
||||||
let logoUrl: String?
|
|
||||||
|
|
||||||
static func vod(
|
static func vod(
|
||||||
url: URL,
|
url: URL,
|
||||||
|
|
@ -30,15 +28,13 @@ struct PlayRequest: Identifiable {
|
||||||
episodeId: String?,
|
episodeId: String?,
|
||||||
seriesId: String?,
|
seriesId: String?,
|
||||||
startMs: Int64,
|
startMs: Int64,
|
||||||
subtitleFileURL: URL? = nil,
|
subtitleFileURL: URL? = nil
|
||||||
logoUrl: String? = nil
|
|
||||||
) -> PlayRequest {
|
) -> PlayRequest {
|
||||||
PlayRequest(
|
PlayRequest(
|
||||||
url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId,
|
url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId,
|
||||||
movieId: movieId, episodeId: episodeId, seriesId: seriesId,
|
movieId: movieId, episodeId: episodeId, seriesId: seriesId,
|
||||||
startMs: startMs, isLive: false, fallbackUrl: nil, subtitleFileURL: subtitleFileURL,
|
startMs: startMs, isLive: false, fallbackUrl: nil, subtitleFileURL: subtitleFileURL,
|
||||||
format: nil, drm: nil, watchSessionId: nil, watchKind: nil, watchId: nil,
|
format: nil, drm: nil, watchSessionId: nil, watchKind: nil, watchId: nil
|
||||||
logoUrl: logoUrl
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,15 +46,14 @@ struct PlayRequest: Identifiable {
|
||||||
drm: DrmInfo? = nil,
|
drm: DrmInfo? = nil,
|
||||||
watchSessionId: String? = nil,
|
watchSessionId: String? = nil,
|
||||||
watchKind: String? = nil,
|
watchKind: String? = nil,
|
||||||
watchId: String? = nil,
|
watchId: String? = nil
|
||||||
logoUrl: String? = nil
|
|
||||||
) -> PlayRequest {
|
) -> PlayRequest {
|
||||||
PlayRequest(
|
PlayRequest(
|
||||||
url: url, title: title, mediaType: "live", mediaFileId: nil,
|
url: url, title: title, mediaType: "live", mediaFileId: nil,
|
||||||
movieId: nil, episodeId: nil, seriesId: nil,
|
movieId: nil, episodeId: nil, seriesId: nil,
|
||||||
startMs: 0, isLive: true, fallbackUrl: fallback, subtitleFileURL: nil,
|
startMs: 0, isLive: true, fallbackUrl: fallback, subtitleFileURL: nil,
|
||||||
format: format, drm: drm, watchSessionId: watchSessionId,
|
format: format, drm: drm, watchSessionId: watchSessionId,
|
||||||
watchKind: watchKind, watchId: watchId, logoUrl: logoUrl
|
watchKind: watchKind, watchId: watchId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +69,7 @@ struct PlayerScreen: View {
|
||||||
|
|
||||||
let request: PlayRequest
|
let request: PlayRequest
|
||||||
|
|
||||||
/// ClearKey: DASH→AVPlayer+proxy; native HLS (ESPN)→VLC+proxy. FTA live + VOD → VLC.
|
/// ClearKey live → AVPlayer + lokale decrypt-HLS. FTA live + VOD → VLC (PiP intact).
|
||||||
@StateObject private var vlc = VLCPlayerController()
|
@StateObject private var vlc = VLCPlayerController()
|
||||||
@StateObject private var av = AVLivePlayerController()
|
@StateObject private var av = AVLivePlayerController()
|
||||||
@State private var controlsVisible = true
|
@State private var controlsVisible = true
|
||||||
|
|
@ -84,35 +79,20 @@ struct PlayerScreen: View {
|
||||||
@State private var hideTask: Task<Void, Never>?
|
@State private var hideTask: Task<Void, Never>?
|
||||||
@State private var heartbeatTask: Task<Void, Never>?
|
@State private var heartbeatTask: Task<Void, Never>?
|
||||||
@State private var showTracks = false
|
@State private var showTracks = false
|
||||||
@State private var sliderDraft: Double = 0
|
|
||||||
@State private var isScrubbing = false
|
|
||||||
@State private var trackTab = 0
|
@State private var trackTab = 0
|
||||||
@State private var subtitleHits: [SubtitleHit] = []
|
@State private var subtitleHits: [SubtitleHit] = []
|
||||||
@State private var subsLoading = false
|
@State private var subsLoading = false
|
||||||
@State private var subsError: String?
|
@State private var subsError: String?
|
||||||
@State private var pendingStop: Task<Void, Never>?
|
@State private var pendingStop: Task<Void, Never>?
|
||||||
|
|
||||||
/// ClearKey live: DASH→AVPlayer; native HLS (ESPN)→VLC (AV bevriest bij rotatie).
|
|
||||||
@State private var clearKeyPreferAV = true
|
|
||||||
|
|
||||||
private var isLive: Bool { request.isLive }
|
private var isLive: Bool { request.isLive }
|
||||||
private var useClearKeyAV: Bool { request.isLive && request.needsClearKey && clearKeyPreferAV }
|
private var useClearKeyAV: Bool { request.isLive && request.needsClearKey }
|
||||||
/// F1-replay e.d. komen via live-request binnen, maar hebben een echte duur.
|
|
||||||
private var showSeekChrome: Bool { !isLive || (!useClearKeyAV && vlc.canSeek) }
|
|
||||||
|
|
||||||
private var stateText: String { useClearKeyAV ? av.stateText : vlc.stateText }
|
private var stateText: String { useClearKeyAV ? av.stateText : vlc.stateText }
|
||||||
private var isPlaying: Bool { useClearKeyAV ? av.isPlaying : vlc.isPlaying }
|
private var isPlaying: Bool { useClearKeyAV ? av.isPlaying : vlc.isPlaying }
|
||||||
private var playerHasError: Bool { useClearKeyAV ? av.hasError : vlc.hasError }
|
private var playerHasError: Bool { useClearKeyAV ? av.hasError : vlc.hasError }
|
||||||
private var playerErrorMessage: String? { useClearKeyAV ? av.errorMessage : vlc.errorMessage }
|
private var playerErrorMessage: String? { useClearKeyAV ? av.errorMessage : vlc.errorMessage }
|
||||||
|
|
||||||
/// Alleen start/buffer vóór first frame — overlay mag playback niet blokkeren.
|
|
||||||
private var showLoadingChrome: Bool {
|
|
||||||
guard !playerHasError else { return false }
|
|
||||||
if loading && !isPlaying { return true }
|
|
||||||
if useClearKeyAV, !av.hasVideo, !av.isPlaying { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
|
|
@ -136,16 +116,18 @@ struct PlayerScreen: View {
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Speler eet touches op — vang tikken altijd zelf af.
|
||||||
Color.clear
|
Color.clear
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.onTapGesture { toggleControls() }
|
.onTapGesture { toggleControls() }
|
||||||
.allowsHitTesting(!controlsVisible)
|
.allowsHitTesting(!controlsVisible)
|
||||||
|
|
||||||
if showLoadingChrome {
|
if loading && !isPlaying {
|
||||||
PlayerLoadingChrome(logoUrl: request.logoUrl, status: stateText)
|
ProgressView(stateText)
|
||||||
|
.tint(.white)
|
||||||
|
.foregroundStyle(.white)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
.transition(.opacity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let error {
|
if let error {
|
||||||
|
|
@ -190,35 +172,13 @@ struct PlayerScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showTracks, onDismiss: { scheduleHide() }) {
|
.sheet(isPresented: $showTracks) {
|
||||||
tracksSheet
|
tracksSheet
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func formatClock(ratio: Double, durationMs: Int64) -> String {
|
|
||||||
let ms = Int(Double(max(durationMs, 0)) * min(max(ratio, 0), 1) / 1000)
|
|
||||||
let h = ms / 3600
|
|
||||||
let m = (ms % 3600) / 60
|
|
||||||
let s = ms % 60
|
|
||||||
if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
|
|
||||||
return String(format: "%d:%02d", m, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Letterbox/pillarbox buiten de VLC-surface zodat FreeType onderaan het beeld zit.
|
|
||||||
private static func fittedVideoSize(in container: CGSize, aspect: CGFloat) -> CGSize {
|
|
||||||
guard container.width > 1, container.height > 1, aspect > 0 else { return container }
|
|
||||||
let viewAspect = container.width / container.height
|
|
||||||
if viewAspect > aspect {
|
|
||||||
let h = container.height
|
|
||||||
return CGSize(width: h * aspect, height: h)
|
|
||||||
} else {
|
|
||||||
let w = container.width
|
|
||||||
return CGSize(width: w, height: w / aspect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var controlsOverlay: some View {
|
private var controlsOverlay: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -241,7 +201,7 @@ struct PlayerScreen: View {
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if !isLive, vlc.pipPossible {
|
if !useClearKeyAV && vlc.pipPossible {
|
||||||
Button {
|
Button {
|
||||||
vlc.togglePictureInPicture()
|
vlc.togglePictureInPicture()
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
|
|
@ -255,17 +215,11 @@ struct PlayerScreen: View {
|
||||||
.background(.black.opacity(0.4), in: Circle())
|
.background(.black.opacity(0.4), in: Circle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !request.isLive {
|
||||||
Button {
|
Button {
|
||||||
showTracks = true
|
showTracks = true
|
||||||
scheduleHide(cancelOnly: true)
|
scheduleHide(cancelOnly: true)
|
||||||
if useClearKeyAV {
|
|
||||||
av.refreshMediaOptions()
|
|
||||||
} else {
|
|
||||||
vlc.refreshTracks()
|
|
||||||
}
|
|
||||||
if !isLive {
|
|
||||||
Task { await loadSubtitles() }
|
Task { await loadSubtitles() }
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "captions.bubble.fill")
|
Image(systemName: "captions.bubble.fill")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
|
|
@ -274,6 +228,7 @@ struct PlayerScreen: View {
|
||||||
.background(.black.opacity(0.4), in: Circle())
|
.background(.black.opacity(0.4), in: Circle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
|
@ -282,38 +237,28 @@ struct PlayerScreen: View {
|
||||||
.onTapGesture { hideControls() }
|
.onTapGesture { hideControls() }
|
||||||
|
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
if showSeekChrome {
|
if !request.isLive {
|
||||||
Slider(
|
Slider(
|
||||||
value: Binding(
|
value: Binding(
|
||||||
get: { isScrubbing ? sliderDraft : Double(vlc.position) },
|
get: { Double(vlc.position) },
|
||||||
set: { sliderDraft = $0 }
|
set: {
|
||||||
),
|
vlc.seek(ratio: Float($0))
|
||||||
in: 0...1,
|
|
||||||
onEditingChanged: { editing in
|
|
||||||
if editing {
|
|
||||||
isScrubbing = true
|
|
||||||
sliderDraft = Double(vlc.position)
|
|
||||||
scheduleHide(cancelOnly: true)
|
|
||||||
} else {
|
|
||||||
isScrubbing = false
|
|
||||||
vlc.seek(ratio: Float(sliderDraft))
|
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
}
|
}
|
||||||
}
|
),
|
||||||
|
in: 0...1
|
||||||
)
|
)
|
||||||
.tint(McColors.accent)
|
.tint(McColors.accent)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(isScrubbing ? Self.formatClock(ratio: sliderDraft, durationMs: vlc.durationMs()) : vlc.timeText)
|
Text(vlc.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
|
||||||
.font(McFonts.caption)
|
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(vlc.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
|
Text(vlc.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 36) {
|
HStack(spacing: 36) {
|
||||||
if showSeekChrome {
|
if !request.isLive {
|
||||||
Button {
|
Button {
|
||||||
vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0))
|
vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0))
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
|
|
@ -330,7 +275,7 @@ struct PlayerScreen: View {
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.shadow(radius: 8)
|
.shadow(radius: 8)
|
||||||
}
|
}
|
||||||
if showSeekChrome {
|
if !request.isLive {
|
||||||
Button {
|
Button {
|
||||||
vlc.seek(toMs: vlc.currentPositionMs() + 10_000)
|
vlc.seek(toMs: vlc.currentPositionMs() + 10_000)
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
|
|
@ -350,61 +295,6 @@ struct PlayerScreen: View {
|
||||||
private var tracksSheet: some View {
|
private var tracksSheet: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if useClearKeyAV {
|
|
||||||
Picker("", selection: $trackTab) {
|
|
||||||
Text("Audio").tag(0)
|
|
||||||
Text("Ondertitel").tag(1)
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.padding()
|
|
||||||
|
|
||||||
if trackTab == 0 {
|
|
||||||
List {
|
|
||||||
if av.audioOptions.isEmpty {
|
|
||||||
Text("Geen audio-sporen gevonden")
|
|
||||||
.foregroundStyle(McColors.textDim)
|
|
||||||
.listRowBackground(McColors.bgElevated)
|
|
||||||
}
|
|
||||||
ForEach(av.audioOptions) { track in
|
|
||||||
Button {
|
|
||||||
av.selectAudio(id: track.id)
|
|
||||||
showTracks = false
|
|
||||||
scheduleHide()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text(track.name).foregroundStyle(McColors.text)
|
|
||||||
Spacer()
|
|
||||||
if track.id == av.currentAudioId {
|
|
||||||
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(McColors.bgElevated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
} else {
|
|
||||||
List {
|
|
||||||
ForEach(av.subtitleOptions) { track in
|
|
||||||
Button {
|
|
||||||
av.selectSubtitle(id: track.id == "off" ? nil : track.id)
|
|
||||||
showTracks = false
|
|
||||||
scheduleHide()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text(track.name).foregroundStyle(McColors.text)
|
|
||||||
Spacer()
|
|
||||||
if track.id == (av.currentSubtitleId ?? "off") {
|
|
||||||
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(McColors.bgElevated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Picker("", selection: $trackTab) {
|
Picker("", selection: $trackTab) {
|
||||||
Text("In bestand").tag(0)
|
Text("In bestand").tag(0)
|
||||||
Text("Downloaden").tag(1)
|
Text("Downloaden").tag(1)
|
||||||
|
|
@ -435,13 +325,7 @@ struct PlayerScreen: View {
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
} else if trackTab == 1 {
|
} else if trackTab == 1 {
|
||||||
Group {
|
Group {
|
||||||
if isLive {
|
if subsLoading {
|
||||||
ContentUnavailableView(
|
|
||||||
"Alleen bij VOD",
|
|
||||||
systemImage: "arrow.down.circle",
|
|
||||||
description: Text("OpenSubtitles is beschikbaar bij films/series.")
|
|
||||||
)
|
|
||||||
} else if subsLoading {
|
|
||||||
ProgressView("Zoeken…").padding(.top, 40)
|
ProgressView("Zoeken…").padding(.top, 40)
|
||||||
} else if let subsError {
|
} else if let subsError {
|
||||||
Text(subsError).foregroundStyle(McColors.danger).padding()
|
Text(subsError).foregroundStyle(McColors.danger).padding()
|
||||||
|
|
@ -493,7 +377,6 @@ struct PlayerScreen: View {
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.background(McColors.bg)
|
.background(McColors.bg)
|
||||||
.navigationTitle("Sporen")
|
.navigationTitle("Sporen")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|
@ -502,14 +385,7 @@ struct PlayerScreen: View {
|
||||||
Button("Sluiten") { showTracks = false }
|
Button("Sluiten") { showTracks = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear { vlc.refreshTracks() }
|
||||||
if useClearKeyAV {
|
|
||||||
av.refreshMediaOptions()
|
|
||||||
trackTab = 0
|
|
||||||
} else {
|
|
||||||
vlc.refreshTracks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -565,33 +441,8 @@ struct PlayerScreen: View {
|
||||||
private func start() async {
|
private func start() async {
|
||||||
loading = true
|
loading = true
|
||||||
error = nil
|
error = nil
|
||||||
if request.isLive && request.needsClearKey {
|
if useClearKeyAV {
|
||||||
let keys = request.drm?.keys ?? []
|
let keys = request.drm?.keys ?? []
|
||||||
let fmt = (request.format ?? "").lowercased()
|
|
||||||
// ESPN e.d.: native HLS + aparte AUDIO-rendition → AVPlayer bevriest beeld bij rotatie.
|
|
||||||
// DASH→HLS (Prime/HBO/Viaplay/Ziggo) blijft op AVPlayer (rotatie is daar vloeiend).
|
|
||||||
let nativeHls = fmt == "hls" || ClearKeyDashProxy.isHlsURL(request.url)
|
|
||||||
if nativeHls {
|
|
||||||
clearKeyPreferAV = false
|
|
||||||
await vlc.playLive(
|
|
||||||
url: request.url,
|
|
||||||
fallback: request.fallbackUrl,
|
|
||||||
format: request.format,
|
|
||||||
clearKeys: keys,
|
|
||||||
preferHls: true
|
|
||||||
)
|
|
||||||
let maxWaitNs: UInt64 = 18_000_000_000
|
|
||||||
let stepNs: UInt64 = 200_000_000
|
|
||||||
var waited: UInt64 = 0
|
|
||||||
while waited < maxWaitNs && !vlc.isPlaying && !vlc.hasError {
|
|
||||||
try? await Task.sleep(nanoseconds: stepNs)
|
|
||||||
waited += stepNs
|
|
||||||
}
|
|
||||||
if vlc.hasError || !vlc.isPlaying {
|
|
||||||
error = vlc.errorMessage ?? "Kon deze ClearKey-livestream niet starten."
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clearKeyPreferAV = true
|
|
||||||
await av.play(
|
await av.play(
|
||||||
url: request.url,
|
url: request.url,
|
||||||
fallback: request.fallbackUrl,
|
fallback: request.fallbackUrl,
|
||||||
|
|
@ -606,32 +457,10 @@ struct PlayerScreen: View {
|
||||||
try? await Task.sleep(nanoseconds: stepNs)
|
try? await Task.sleep(nanoseconds: stepNs)
|
||||||
waited += stepNs
|
waited += stepNs
|
||||||
}
|
}
|
||||||
if av.isPlaying && !av.hasError {
|
if !av.isPlaying || av.hasError {
|
||||||
loading = false
|
error = av.errorMessage
|
||||||
return
|
|
||||||
}
|
|
||||||
let avFailHint = av.errorMessage
|
|
||||||
av.stop()
|
|
||||||
clearKeyPreferAV = false
|
|
||||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
|
||||||
await vlc.playLive(
|
|
||||||
url: request.url,
|
|
||||||
fallback: request.fallbackUrl,
|
|
||||||
format: request.format,
|
|
||||||
clearKeys: keys,
|
|
||||||
preferHls: true
|
|
||||||
)
|
|
||||||
waited = 0
|
|
||||||
while waited < 8_000_000_000 && !vlc.isPlaying && !vlc.hasError {
|
|
||||||
try? await Task.sleep(nanoseconds: stepNs)
|
|
||||||
waited += stepNs
|
|
||||||
}
|
|
||||||
if vlc.hasError || !vlc.isPlaying {
|
|
||||||
error = vlc.errorMessage
|
|
||||||
?? avFailHint
|
|
||||||
?? "Kon deze ClearKey-livestream niet starten."
|
?? "Kon deze ClearKey-livestream niet starten."
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else if isLive {
|
} else if isLive {
|
||||||
await vlc.playLive(
|
await vlc.playLive(
|
||||||
url: request.url,
|
url: request.url,
|
||||||
|
|
@ -648,8 +477,6 @@ struct PlayerScreen: View {
|
||||||
}
|
}
|
||||||
if vlc.hasError {
|
if vlc.hasError {
|
||||||
error = vlc.errorMessage ?? "Kon deze livestream niet starten."
|
error = vlc.errorMessage ?? "Kon deze livestream niet starten."
|
||||||
} else if !vlc.isPlaying {
|
|
||||||
error = vlc.errorMessage ?? "Livestream startte niet (geen beeld/geluid)."
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
vlc.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
|
vlc.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
|
||||||
|
|
@ -733,6 +560,18 @@ struct PlayerScreen: View {
|
||||||
subsError = error.localizedDescription
|
subsError = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func fittedVideoSize(in container: CGSize, aspect: CGFloat) -> CGSize {
|
||||||
|
guard container.width > 0, container.height > 0, aspect > 0 else { return container }
|
||||||
|
let containerAspect = container.width / container.height
|
||||||
|
if containerAspect > aspect {
|
||||||
|
let h = container.height
|
||||||
|
return CGSize(width: h * aspect, height: h)
|
||||||
|
} else {
|
||||||
|
let w = container.width
|
||||||
|
return CGSize(width: w, height: w / aspect)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlayLauncher: View {
|
struct PlayLauncher: View {
|
||||||
|
|
@ -744,7 +583,6 @@ struct PlayLauncher: View {
|
||||||
let episodeId: String?
|
let episodeId: String?
|
||||||
let seriesId: String?
|
let seriesId: String?
|
||||||
let startMs: Int64
|
let startMs: Int64
|
||||||
var logoUrl: String? = nil
|
|
||||||
|
|
||||||
@State private var request: PlayRequest?
|
@State private var request: PlayRequest?
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
@ -756,7 +594,7 @@ struct PlayLauncher: View {
|
||||||
if let request {
|
if let request {
|
||||||
PlayerScreen(request: request)
|
PlayerScreen(request: request)
|
||||||
} else if loading {
|
} else if loading {
|
||||||
PlayerLoadingChrome(logoUrl: logoUrl, status: "Stream voorbereiden…")
|
ProgressView("Stream voorbereiden…").tint(.white).foregroundStyle(.white)
|
||||||
} else if let error {
|
} else if let error {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center).padding()
|
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center).padding()
|
||||||
|
|
@ -796,8 +634,7 @@ struct PlayLauncher: View {
|
||||||
request = .vod(
|
request = .vod(
|
||||||
url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId,
|
url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId,
|
||||||
movieId: movieId, episodeId: episodeId, seriesId: seriesId, startMs: startMs,
|
movieId: movieId, episodeId: episodeId, seriesId: seriesId, startMs: startMs,
|
||||||
subtitleFileURL: subtitleURL,
|
subtitleFileURL: subtitleURL
|
||||||
logoUrl: logoUrl
|
|
||||||
)
|
)
|
||||||
loading = false
|
loading = false
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -806,65 +643,3 @@ struct PlayLauncher: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Centraal logo (provider / film / serie) met zachte pulse i.p.v. kale ProgressView.
|
|
||||||
struct PlayerLoadingChrome: View {
|
|
||||||
let logoUrl: String?
|
|
||||||
let status: String
|
|
||||||
@State private var pulse = false
|
|
||||||
@State private var glow = false
|
|
||||||
|
|
||||||
private var hasLogo: Bool {
|
|
||||||
guard let logoUrl, !logoUrl.isEmpty, URL(string: logoUrl) != nil else { return false }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Color.black.opacity(0.55).ignoresSafeArea()
|
|
||||||
|
|
||||||
VStack(spacing: 22) {
|
|
||||||
if hasLogo {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(
|
|
||||||
RadialGradient(
|
|
||||||
colors: [
|
|
||||||
Color.white.opacity(glow ? 0.18 : 0.06),
|
|
||||||
Color.clear,
|
|
||||||
],
|
|
||||||
center: .center,
|
|
||||||
startRadius: 8,
|
|
||||||
endRadius: 140
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.frame(width: 280, height: 280)
|
|
||||||
.scaleEffect(glow ? 1.08 : 0.92)
|
|
||||||
|
|
||||||
RemoteImage(url: logoUrl, contentMode: .fit, opaqueBackground: false)
|
|
||||||
.frame(maxWidth: 240, maxHeight: 96)
|
|
||||||
.scaleEffect(pulse ? 1.04 : 0.96)
|
|
||||||
.opacity(pulse ? 1.0 : 0.72)
|
|
||||||
.shadow(color: .black.opacity(0.55), radius: 18, y: 8)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
withAnimation(.easeInOut(duration: 1.25).repeatForever(autoreverses: true)) {
|
|
||||||
pulse = true
|
|
||||||
glow = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ProgressView()
|
|
||||||
.tint(.white)
|
|
||||||
.scaleEffect(1.2)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(status)
|
|
||||||
.font(McFonts.caption)
|
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 28)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
@Published var currentAudioId: String = ""
|
@Published var currentAudioId: String = ""
|
||||||
@Published var pipPossible = false
|
@Published var pipPossible = false
|
||||||
@Published var pipActive = false
|
@Published var pipActive = false
|
||||||
/// VOD / F1-replay via playLive: finite duration + VLC seekable.
|
|
||||||
@Published var canSeek = false
|
|
||||||
|
|
||||||
static let offSubtitleId = PlayerTrack.offId
|
static let offSubtitleId = PlayerTrack.offId
|
||||||
|
|
||||||
|
|
@ -34,12 +32,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
private let drawableHost = VLCPipDrawableHost()
|
private let drawableHost = VLCPipDrawableHost()
|
||||||
/// Plain UIView-target voor live/ClearKey (PiP sample-buffer-vout breekt DASH/proxy).
|
/// Plain UIView-target voor live/ClearKey (PiP sample-buffer-vout breekt DASH/proxy).
|
||||||
private weak var plainVideoView: UIView?
|
private weak var plainVideoView: UIView?
|
||||||
/// Sterke ref zolang er gespeeld wordt — voorkomt crash als SwiftUI de surface al freeggeeft.
|
|
||||||
private var retainedSurface: UIView?
|
|
||||||
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
|
||||||
private var clearKeyProxySession = 0
|
|
||||||
private var liveMode = false
|
private var liveMode = false
|
||||||
private var livePlayURL: URL?
|
private var livePlayURL: URL?
|
||||||
private var livePlayFormat: String?
|
private var livePlayFormat: String?
|
||||||
|
|
@ -50,14 +45,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
private var sceneObserver: NSObjectProtocol?
|
private var sceneObserver: NSObjectProtocol?
|
||||||
/// Voorkomt herhaald `drawable=` bij SwiftUI-ticks (audio-zonder-beeld).
|
/// Voorkomt herhaald `drawable=` bij SwiftUI-ticks (audio-zonder-beeld).
|
||||||
private weak var attachedView: UIView?
|
private weak var attachedView: UIView?
|
||||||
/// VOD: altijd PiP-capable drawable (officieel VLCKit-patroon). Live/ClearKey: plain UIView.
|
/// VOD: plain UIView voor FreeType-subs; PiP-drawable alleen bij PiP-start.
|
||||||
private var usePipDrawable = false
|
private var usePipDrawable = false
|
||||||
private var stopping = false
|
private var stopping = false
|
||||||
private var stopGeneration = 0
|
|
||||||
private var didApplyPreferredAudio = false
|
|
||||||
private var knownVodDurationMs: Int64 = 0
|
|
||||||
/// Playlist is geknipt vanaf dit punt (VLC-tijd 0 = deze offset).
|
|
||||||
private var seekOriginMs: Int64 = 0
|
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
|
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
|
||||||
|
|
@ -74,110 +64,46 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
if let sceneObserver {
|
if let sceneObserver {
|
||||||
NotificationCenter.default.removeObserver(sceneObserver)
|
NotificationCenter.default.removeObserver(sceneObserver)
|
||||||
}
|
}
|
||||||
mediaPlayer.stop()
|
|
||||||
mediaPlayer.drawable = nil
|
mediaPlayer.drawable = nil
|
||||||
|
mediaPlayer.stop()
|
||||||
if clearKeyProxyActive {
|
if clearKeyProxyActive {
|
||||||
ClearKeyDashProxy.shared.stop(session: clearKeyProxySession)
|
ClearKeyDashProxy.shared.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func attach(to view: UIView) {
|
func attach(to view: UIView) {
|
||||||
guard !stopping else { return }
|
if attachedView === view { return }
|
||||||
attachedView = view
|
attachedView = view
|
||||||
plainVideoView = view
|
plainVideoView = view
|
||||||
retainedSurface = view
|
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
|
applyDrawable()
|
||||||
// PiP-restore: container mag wisselen zonder mediaPlayer.drawable te resetten.
|
|
||||||
if usePipDrawable, mediaPlayer.drawable as AnyObject? === drawableHost {
|
|
||||||
drawableHost.attach(to: view)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if mediaPlayer.drawable as AnyObject? === view {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Nooit mid-play herbinden als er al een geldige drawable hangt.
|
|
||||||
if mediaPlayer.drawable != nil, mediaPlayer.media != nil {
|
|
||||||
if usePipDrawable {
|
|
||||||
drawableHost.attach(to: view)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
applyDrawable(force: mediaPlayer.drawable == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Layout-callback: bind alleen als size OK; sta herstel toe als eerder op 0×0 gebonden.
|
/// Live/ClearKey of VOD-inline → plain UIView (FreeType). PiP → sample-buffer drawable.
|
||||||
func surfaceDidLayout(_ view: UIView) {
|
private func applyDrawable() {
|
||||||
guard !stopping else { return }
|
|
||||||
plainVideoView = view
|
|
||||||
attachedView = view
|
|
||||||
retainedSurface = view
|
|
||||||
guard view.bounds.width > 1, view.bounds.height > 1 else { return }
|
|
||||||
|
|
||||||
if usePipDrawable, mediaPlayer.drawable as AnyObject? === drawableHost {
|
|
||||||
drawableHost.attach(to: view)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if mediaPlayer.drawable == nil {
|
|
||||||
applyDrawable(force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Live/ClearKey → plain UIView. VOD → VLCPipDrawableHost (PiP vanaf start).
|
|
||||||
private func applyDrawable(force: Bool = false) {
|
|
||||||
guard !stopping else { return }
|
|
||||||
guard let view = plainVideoView else { return }
|
guard let view = plainVideoView else { return }
|
||||||
// Nooit binden op 0×0 — VLC opent dan vout zonder beeld (audio-only).
|
let wantPip = usePipDrawable && !liveMode && !clearKeyProxyActive
|
||||||
guard view.bounds.width > 1, view.bounds.height > 1 else { return }
|
if wantPip {
|
||||||
let wantPipHost = !liveMode && !clearKeyProxyActive
|
|
||||||
usePipDrawable = wantPipHost
|
|
||||||
if wantPipHost {
|
|
||||||
drawableHost.attach(to: view)
|
drawableHost.attach(to: view)
|
||||||
if force || mediaPlayer.drawable as AnyObject? !== drawableHost {
|
if mediaPlayer.drawable as AnyObject? !== drawableHost {
|
||||||
mediaPlayer.drawable = drawableHost
|
mediaPlayer.drawable = drawableHost
|
||||||
}
|
}
|
||||||
if pipWindow != nil {
|
|
||||||
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// ClearKey/live: plain UIView. Nooit detach/rebind als al gezet tijdens play.
|
|
||||||
if mediaPlayer.drawable as AnyObject? === view, !force { return }
|
|
||||||
if mediaPlayer.media != nil, mediaPlayer.drawable != nil, !force { return }
|
|
||||||
drawableHost.detachVideo()
|
drawableHost.detachVideo()
|
||||||
|
if mediaPlayer.drawable as AnyObject? !== view {
|
||||||
mediaPlayer.drawable = view
|
mediaPlayer.drawable = view
|
||||||
|
}
|
||||||
|
if liveMode || clearKeyProxyActive {
|
||||||
pipPossible = false
|
pipPossible = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func startPictureInPicture() {
|
func startPictureInPicture() {
|
||||||
guard !liveMode, !clearKeyProxyActive, !stopping else { return }
|
preparePipDrawableThen {
|
||||||
// FreeType-subs zitten niet in VLCKit sample-buffer PiP (Apple/VLC-beperking).
|
self.pipWindow?.startPictureInPicture()
|
||||||
if currentSubtitleId != PlayerTrack.offId {
|
|
||||||
stateText = "PiP zonder ondertiteling"
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in
|
|
||||||
guard let self, !self.stopping, self.stateText == "PiP zonder ondertiteling" else { return }
|
|
||||||
self.stateText = self.isPlaying ? "Afspelen" : self.stateText
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let pipWindow {
|
|
||||||
pipWindow.startPictureInPicture()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
applyDrawable(force: true)
|
|
||||||
let gen = stopGeneration
|
|
||||||
var attempts = 0
|
|
||||||
func tryStart() {
|
|
||||||
guard self.stopGeneration == gen, !self.stopping else { return }
|
|
||||||
guard !self.liveMode, !self.clearKeyProxyActive else { return }
|
|
||||||
attempts += 1
|
|
||||||
if let win = self.pipWindow {
|
|
||||||
win.startPictureInPicture()
|
|
||||||
} else if attempts < 20 {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: tryStart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: tryStart)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopPictureInPicture() {
|
func stopPictureInPicture() {
|
||||||
pipWindow?.stopPictureInPicture()
|
pipWindow?.stopPictureInPicture()
|
||||||
|
|
@ -188,23 +114,44 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
else { startPictureInPicture() }
|
else { startPictureInPicture() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func preparePipDrawableThen(_ work: @escaping () -> Void) {
|
||||||
|
if usePipDrawable, pipWindow != nil {
|
||||||
|
work()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usePipDrawable = true
|
||||||
|
applyDrawable()
|
||||||
|
// pictureInPictureReady komt async; kort wachten op pipWindow.
|
||||||
|
var attempts = 0
|
||||||
|
func tryStart() {
|
||||||
|
attempts += 1
|
||||||
|
if self.pipWindow != nil || attempts >= 12 {
|
||||||
|
work()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: tryStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: tryStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restorePlainDrawableAfterPip() {
|
||||||
|
guard !liveMode, !clearKeyProxyActive, !stopping else { return }
|
||||||
|
usePipDrawable = false
|
||||||
|
applyDrawable()
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) {
|
func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) {
|
||||||
// Annuleer pending finishStop van eerdere/spurious onDisappear.
|
|
||||||
stopGeneration &+= 1
|
|
||||||
stopping = false
|
|
||||||
hasError = false
|
hasError = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
stateText = "Verbinden…"
|
stateText = "Verbinden…"
|
||||||
subtitleTracks = []
|
subtitleTracks = []
|
||||||
audioTracks = []
|
audioTracks = []
|
||||||
canSeek = false
|
|
||||||
didApplyPreferredAudio = false
|
|
||||||
knownVodDurationMs = 0
|
|
||||||
seekOriginMs = 0
|
|
||||||
liveMode = false
|
liveMode = false
|
||||||
clearKeyProxyActive = false
|
clearKeyProxyActive = false
|
||||||
plainVideoView?.layoutIfNeeded()
|
usePipDrawable = false
|
||||||
applyDrawable(force: true)
|
applyDrawable()
|
||||||
|
// PiP-knop tonen; drawable wisselt pas bij PiP-start (FreeType blijft werken).
|
||||||
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
|
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
|
||||||
guard let media = VLCMedia(url: url) else {
|
guard let media = VLCMedia(url: url) else {
|
||||||
hasError = true
|
hasError = true
|
||||||
|
|
@ -213,38 +160,35 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
media.delegate = self
|
media.delegate = self
|
||||||
media.addOption(":network-caching=2000")
|
media.addOption(":network-caching=2500")
|
||||||
media.addOption(":http-reconnect")
|
media.addOption(":http-reconnect")
|
||||||
media.addOption(":avcodec-hw=any")
|
media.addOption(":avcodec-hw=any")
|
||||||
media.addOption(":sout-mux-caching=1200")
|
media.addOption(":sout-mux-caching=1500")
|
||||||
media.addOption(":sub-track=0")
|
media.addOption(":sub-track=0")
|
||||||
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
||||||
media.addOption(":freetype-bold=1")
|
media.addOption(":freetype-bold=1")
|
||||||
media.addOption(":sub-margin=0")
|
|
||||||
mediaPlayer.media = media
|
mediaPlayer.media = media
|
||||||
|
// VLCKit 4: parse is on VLCMediaParser, not VLCMedia
|
||||||
_ = VLCMediaParser.shared().queue(
|
_ = VLCMediaParser.shared().queue(
|
||||||
media,
|
media,
|
||||||
options: VLCMediaParsingOptions(rawValue: 0x02 | 0x08)
|
options: VLCMediaParsingOptions(rawValue: 0x02 | 0x08) // network + fetchLocal
|
||||||
)
|
)
|
||||||
mediaPlayer.play()
|
mediaPlayer.play()
|
||||||
applySubtitleStyle()
|
applySubtitleStyle()
|
||||||
if startMs > 2000 {
|
if startMs > 2000 {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
|
||||||
guard let self, !self.stopping else { return }
|
self?.seek(toMs: startMs)
|
||||||
self.seek(toMs: startMs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let subtitleFileURL {
|
if let subtitleFileURL {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
|
||||||
guard let self, !self.stopping else { return }
|
self?.addExternalSubtitle(url: subtitleFileURL)
|
||||||
self.addExternalSubtitle(url: subtitleFileURL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
startTicker()
|
startTicker()
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||||
guard let self, !self.stopping else { return }
|
self?.refreshTracks()
|
||||||
self.refreshTracks()
|
self?.applySubtitleStyle()
|
||||||
self.applySubtitleStyle()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,55 +196,29 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
url: URL,
|
url: URL,
|
||||||
fallback: URL? = nil,
|
fallback: URL? = nil,
|
||||||
format: String? = nil,
|
format: String? = nil,
|
||||||
clearKeys: [ClearKeyEntry] = [],
|
clearKeys: [ClearKeyEntry] = []
|
||||||
preferHls: Bool = false
|
|
||||||
) async {
|
) async {
|
||||||
// Annuleer pending finishStop / delayed proxy-stop van vorige sessie.
|
|
||||||
stopGeneration &+= 1
|
|
||||||
let playGen = stopGeneration
|
|
||||||
stopping = false
|
|
||||||
hasError = false
|
hasError = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
stateText = "Live laden…"
|
stateText = "Live laden…"
|
||||||
liveRetryCount = 0
|
liveRetryCount = 0
|
||||||
everPlayed = false
|
everPlayed = false
|
||||||
liveStartedAt = Date()
|
liveStartedAt = Date()
|
||||||
canSeek = false
|
|
||||||
didApplyPreferredAudio = false
|
|
||||||
knownVodDurationMs = 0
|
|
||||||
seekOriginMs = 0
|
|
||||||
liveMode = true
|
liveMode = true
|
||||||
usePipDrawable = false
|
usePipDrawable = false
|
||||||
pipPossible = false
|
|
||||||
|
|
||||||
var playURL = url
|
var playURL = url
|
||||||
var playFormat = format
|
var playFormat = format
|
||||||
clearKeyProxyActive = false
|
clearKeyProxyActive = false
|
||||||
var proxySession = 0
|
|
||||||
|
|
||||||
if !clearKeys.isEmpty {
|
if !clearKeys.isEmpty {
|
||||||
do {
|
do {
|
||||||
stateText = "ClearKey laden…"
|
stateText = "ClearKey laden…"
|
||||||
let started = try await ClearKeyDashProxy.shared.start(
|
// Async: blokkeer de main thread niet (watchdog-crash).
|
||||||
manifestURL: url,
|
// HLS (ESPN) én DASH (Prime) via dezelfde proxy.
|
||||||
keys: clearKeys,
|
let started = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
|
||||||
preferHlsForAVPlayer: preferHls
|
|
||||||
)
|
|
||||||
playURL = started.url
|
playURL = started.url
|
||||||
proxySession = started.session
|
playFormat = ClearKeyDashProxy.isHlsURL(url) ? "hls" : "dash"
|
||||||
if started.vodDurationMs > 3000 {
|
|
||||||
knownVodDurationMs = started.vodDurationMs
|
|
||||||
let clamped = Int32(min(started.vodDurationMs, Int64(Int32.max)))
|
|
||||||
lengthMs = clamped
|
|
||||||
lengthText = Self.format(ms: clamped)
|
|
||||||
canSeek = true
|
|
||||||
}
|
|
||||||
// Lokale clear-HLS of remote HLS.
|
|
||||||
if preferHls || ClearKeyDashProxy.isHlsURL(started.url) || ClearKeyDashProxy.isHlsURL(url) {
|
|
||||||
playFormat = "hls"
|
|
||||||
} else {
|
|
||||||
playFormat = "dash"
|
|
||||||
}
|
|
||||||
clearKeyProxyActive = true
|
clearKeyProxyActive = true
|
||||||
} catch {
|
} catch {
|
||||||
hasError = true
|
hasError = true
|
||||||
|
|
@ -310,48 +228,21 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard stopGeneration == playGen, !stopping else { return }
|
applyDrawable()
|
||||||
|
|
||||||
await waitForSurface(timeout: 2.0)
|
|
||||||
await MainActor.run {
|
|
||||||
plainVideoView?.layoutIfNeeded()
|
|
||||||
applyDrawable(force: true)
|
|
||||||
}
|
|
||||||
guard stopGeneration == playGen, !stopping else { return }
|
|
||||||
|
|
||||||
livePlayURL = playURL
|
livePlayURL = playURL
|
||||||
livePlayFormat = playFormat
|
livePlayFormat = playFormat
|
||||||
clearKeyProxySession = proxySession
|
|
||||||
startLiveMedia(url: playURL, format: playFormat)
|
startLiveMedia(url: playURL, format: playFormat)
|
||||||
|
|
||||||
if let fallback, clearKeys.isEmpty {
|
if let fallback, clearKeys.isEmpty {
|
||||||
let gen = stopGeneration
|
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
|
||||||
// Snellere fallback: IPTV-HLS faalt vaak stil; probeer binnen 3.5s de fallback.
|
guard let self, !self.isPlaying || self.hasError else { return }
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in
|
|
||||||
guard let self, self.stopGeneration == gen, !self.stopping else { return }
|
|
||||||
guard !self.everPlayed else { return }
|
|
||||||
guard !self.isPlaying || self.hasError else { return }
|
|
||||||
self.stateText = "Fallback…"
|
|
||||||
self.startLiveMedia(url: fallback, format: nil)
|
self.startLiveMedia(url: fallback, format: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
startTicker()
|
startTicker()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func waitForSurface(timeout: TimeInterval) async {
|
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
|
||||||
while Date() < deadline {
|
|
||||||
let ready = await MainActor.run { () -> Bool in
|
|
||||||
guard let view = plainVideoView else { return false }
|
|
||||||
return view.bounds.width > 1 && view.bounds.height > 1
|
|
||||||
}
|
|
||||||
if ready { return }
|
|
||||||
try? await Task.sleep(nanoseconds: 40_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startLiveMedia(url: URL, format: String?) {
|
private func startLiveMedia(url: URL, format: String?) {
|
||||||
guard !stopping else { return }
|
|
||||||
guard let media = VLCMedia(url: url) else {
|
guard let media = VLCMedia(url: url) else {
|
||||||
hasError = true
|
hasError = true
|
||||||
errorMessage = "Kon live-stream niet openen."
|
errorMessage = "Kon live-stream niet openen."
|
||||||
|
|
@ -359,28 +250,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
media.delegate = self
|
media.delegate = self
|
||||||
Self.applyLiveOptions(
|
Self.applyLiveOptions(to: media, url: url, format: format)
|
||||||
to: media,
|
|
||||||
url: url,
|
|
||||||
format: format,
|
|
||||||
viaLocalProxy: clearKeyProxyActive,
|
|
||||||
asVod: knownVodDurationMs > 3000
|
|
||||||
)
|
|
||||||
// Drawable één keer vóór play — nooit opnieuw mid-stream (audio-only / crash).
|
|
||||||
if mediaPlayer.drawable == nil {
|
|
||||||
applyDrawable(force: true)
|
|
||||||
}
|
|
||||||
mediaPlayer.media = media
|
mediaPlayer.media = media
|
||||||
mediaPlayer.play()
|
mediaPlayer.play()
|
||||||
let gen = stopGeneration
|
applySubtitleStyle()
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in
|
|
||||||
guard let self, self.stopGeneration == gen, !self.stopping else { return }
|
|
||||||
self.refreshTracks()
|
|
||||||
}
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) { [weak self] in
|
|
||||||
guard let self, self.stopGeneration == gen, !self.stopping else { return }
|
|
||||||
self.refreshTracks()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dutchClearKeyError(_ error: Error) -> String {
|
private func dutchClearKeyError(_ error: Error) -> String {
|
||||||
|
|
@ -401,16 +274,16 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
}
|
}
|
||||||
|
|
||||||
private func retryLiveIfNeeded() {
|
private func retryLiveIfNeeded() {
|
||||||
guard liveMode, !everPlayed, liveRetryCount < 2 else { return }
|
guard clearKeyProxyActive, !everPlayed, liveRetryCount < 2 else { return }
|
||||||
guard let url = livePlayURL else { return }
|
guard let url = livePlayURL else { return }
|
||||||
let elapsed = Date().timeIntervalSince(liveStartedAt ?? Date())
|
let elapsed = Date().timeIntervalSince(liveStartedAt ?? Date())
|
||||||
guard elapsed < 14 else { return }
|
guard elapsed < 12 else { return }
|
||||||
liveRetryCount += 1
|
liveRetryCount += 1
|
||||||
stateText = "Opnieuw verbinden…"
|
stateText = "Opnieuw verbinden…"
|
||||||
hasError = false
|
hasError = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
|
||||||
guard let self, self.liveMode, !self.stopping else { return }
|
guard let self, self.clearKeyProxyActive else { return }
|
||||||
self.startLiveMedia(url: url, format: self.livePlayFormat)
|
self.startLiveMedia(url: url, format: self.livePlayFormat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -418,31 +291,16 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
private static func applyLiveOptions(
|
private static func applyLiveOptions(
|
||||||
to media: VLCMedia,
|
to media: VLCMedia,
|
||||||
url: URL,
|
url: URL,
|
||||||
format: String?,
|
format: String?
|
||||||
viaLocalProxy: Bool,
|
|
||||||
asVod: Bool = false
|
|
||||||
) {
|
) {
|
||||||
// Stabiele live-start: te lage caching + drop-late-frames liet streams meteen stoppen.
|
media.addOption(":network-caching=1800")
|
||||||
// VOD (F1-replay): géén live-caching, anders blijft VLC isSeekable=false / duur=0.
|
|
||||||
if asVod {
|
|
||||||
media.addOption(":network-caching=1500")
|
|
||||||
} else if viaLocalProxy {
|
|
||||||
media.addOption(":network-caching=1200")
|
|
||||||
media.addOption(":live-caching=1000")
|
|
||||||
} else {
|
|
||||||
media.addOption(":network-caching=1000")
|
|
||||||
media.addOption(":live-caching=800")
|
|
||||||
}
|
|
||||||
media.addOption(":http-reconnect")
|
media.addOption(":http-reconnect")
|
||||||
|
media.addOption(":live-caching=1500")
|
||||||
media.addOption(":avcodec-hw=any")
|
media.addOption(":avcodec-hw=any")
|
||||||
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
|
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
|
||||||
// IPTV/CDN's weigeren soms exotic UA's.
|
|
||||||
media.addOption(":http-user-agent=VLC/3.0.21 LibVLC/3.0.21")
|
|
||||||
// VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module).
|
// VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module).
|
||||||
media.addOption(":adaptive-logic=highest")
|
media.addOption(":adaptive-logic=highest")
|
||||||
if !viaLocalProxy {
|
|
||||||
media.addOption(":adaptive-use-access")
|
media.addOption(":adaptive-use-access")
|
||||||
}
|
|
||||||
|
|
||||||
let fmt = (format ?? "").lowercased()
|
let fmt = (format ?? "").lowercased()
|
||||||
let path = url.absoluteString.lowercased()
|
let path = url.absoluteString.lowercased()
|
||||||
|
|
@ -459,44 +317,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
}
|
}
|
||||||
|
|
||||||
func seek(toMs: Int64) {
|
func seek(toMs: Int64) {
|
||||||
let target = max(0, toMs)
|
mediaPlayer.time = VLCTime(number: NSNumber(value: toMs))
|
||||||
if knownVodDurationMs > 3000, clearKeyProxyActive, let base = livePlayURL {
|
|
||||||
let endPad = max(knownVodDurationMs - 1500, 0)
|
|
||||||
let clamped = min(target, endPad)
|
|
||||||
seekOriginMs = clamped
|
|
||||||
ClearKeyDashProxy.shared.setPlaybackStartOffset(ms: clamped)
|
|
||||||
var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)
|
|
||||||
var items = (comps?.queryItems ?? []).filter { $0.name != "vmcSeek" }
|
|
||||||
items.append(URLQueryItem(name: "vmcSeek", value: String(clamped)))
|
|
||||||
comps?.queryItems = items
|
|
||||||
if let url = comps?.url {
|
|
||||||
position = knownVodDurationMs > 0 ? Float(Double(clamped) / Double(knownVodDurationMs)) : 0
|
|
||||||
timeText = Self.format(ms: Int32(min(clamped, Int64(Int32.max))))
|
|
||||||
startLiveMedia(url: url, format: livePlayFormat)
|
|
||||||
invalidatePipPlaybackState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mediaPlayer.time = VLCTime(number: NSNumber(value: target))
|
|
||||||
invalidatePipPlaybackState()
|
invalidatePipPlaybackState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func seek(ratio: Float) {
|
func seek(ratio: Float) {
|
||||||
let dur = durationMs()
|
|
||||||
if dur > 3000 {
|
|
||||||
seek(toMs: Int64(Double(dur) * Double(min(max(ratio, 0), 1))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mediaPlayer.position = Double(ratio)
|
mediaPlayer.position = Double(ratio)
|
||||||
invalidatePipPlaybackState()
|
invalidatePipPlaybackState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentPositionMs() -> Int64 {
|
func currentPositionMs() -> Int64 { Int64(mediaPlayer.time.intValue) }
|
||||||
Int64(mediaPlayer.time.intValue) + seekOriginMs
|
|
||||||
}
|
|
||||||
|
|
||||||
func durationMs() -> Int64 {
|
func durationMs() -> Int64 {
|
||||||
if knownVodDurationMs > 3000 { return knownVodDurationMs }
|
|
||||||
let v = mediaPlayer.media?.length.intValue ?? lengthMs
|
let v = mediaPlayer.media?.length.intValue ?? lengthMs
|
||||||
return Int64(max(v, 0))
|
return Int64(max(v, 0))
|
||||||
}
|
}
|
||||||
|
|
@ -524,9 +356,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
if pipActive {
|
if pipActive {
|
||||||
stopPictureInPicture()
|
stopPictureInPicture()
|
||||||
}
|
}
|
||||||
|
if usePipDrawable {
|
||||||
|
usePipDrawable = false
|
||||||
|
applyDrawable()
|
||||||
|
}
|
||||||
_ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
_ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
guard let self, !self.stopping else { return }
|
guard let self else { return }
|
||||||
self.refreshTracks()
|
self.refreshTracks()
|
||||||
self.applySubtitleStyle()
|
self.applySubtitleStyle()
|
||||||
if let last = self.subtitleTracks.last(where: { $0.id != PlayerTrack.offId }) {
|
if let last = self.subtitleTracks.last(where: { $0.id != PlayerTrack.offId }) {
|
||||||
|
|
@ -547,7 +383,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
|
|
||||||
func refreshTracks() {
|
func refreshTracks() {
|
||||||
// VLCKit 4 kan crashen op track-API tijdens error/opening — altijd defensief.
|
// VLCKit 4 kan crashen op track-API tijdens error/opening — altijd defensief.
|
||||||
guard !stopping, mediaPlayer.media != nil else {
|
guard mediaPlayer.media != nil else {
|
||||||
subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")]
|
subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")]
|
||||||
audioTracks = []
|
audioTracks = []
|
||||||
return
|
return
|
||||||
|
|
@ -589,40 +425,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
}
|
}
|
||||||
audioTracks = audios
|
audioTracks = audios
|
||||||
currentAudioId = selectedAudio
|
currentAudioId = selectedAudio
|
||||||
applyPreferredAudioIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyPreferredAudioIfNeeded() {
|
|
||||||
guard !didApplyPreferredAudio else { return }
|
|
||||||
let list = mediaPlayer.audioTracks
|
|
||||||
guard !list.isEmpty else { return }
|
|
||||||
didApplyPreferredAudio = true
|
|
||||||
let ranked = list.enumerated().map { i, t in
|
|
||||||
(track: t, score: Self.audioPreferenceScore(language: t.language, name: t.trackName, description: t.trackDescription, index: i))
|
|
||||||
}
|
|
||||||
guard let best = ranked.max(by: { $0.score < $1.score }), best.score > 0 else { return }
|
|
||||||
if !best.track.isSelected {
|
|
||||||
best.track.isSelectedExclusively = true
|
|
||||||
}
|
|
||||||
currentAudioId = best.track.trackId
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NL eerst, anders Engels. Geen match → 0 (niet forceren).
|
|
||||||
static func audioPreferenceScore(language: String?, name: String?, description: String?, index: Int) -> Int {
|
|
||||||
let blob = [language, name, description]
|
|
||||||
.compactMap { $0?.lowercased() }
|
|
||||||
.joined(separator: " ")
|
|
||||||
if blob.isEmpty { return 0 }
|
|
||||||
let nl = ["nederlands", "dutch", " holland", "nl ", "dut", "nld"]
|
|
||||||
if nl.contains(where: { blob.contains($0) }) || blob == "nl" || blob.hasPrefix("nl-") || blob.hasPrefix("nl_") {
|
|
||||||
return 100 - index
|
|
||||||
}
|
|
||||||
let en = ["engels", "english", "international", "eng", "en "]
|
|
||||||
if en.contains(where: { blob.contains($0) }) || blob == "en" || blob.hasPrefix("en-") || blob.hasPrefix("en_")
|
|
||||||
|| blob == "eng" {
|
|
||||||
return 50 - index
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum TrackKind { case subtitle, audio }
|
private enum TrackKind { case subtitle, audio }
|
||||||
|
|
@ -738,85 +540,49 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
func stop() {
|
func stop() {
|
||||||
guard !stopping else { return }
|
guard !stopping else { return }
|
||||||
stopping = true
|
stopping = true
|
||||||
stopGeneration &+= 1
|
|
||||||
let gen = stopGeneration
|
|
||||||
let proxySession = clearKeyProxySession
|
|
||||||
tick?.cancel()
|
tick?.cancel()
|
||||||
tick = nil
|
tick = nil
|
||||||
|
|
||||||
let proxyWasActive = clearKeyProxyActive
|
let proxyWasActive = clearKeyProxyActive
|
||||||
let wasPip = pipActive
|
|
||||||
clearKeyProxyActive = false
|
clearKeyProxyActive = false
|
||||||
clearKeyProxySession = 0
|
|
||||||
pipPossible = false
|
|
||||||
|
|
||||||
// PiP eerst netjes stoppen en wachten — sync nil + media stop = EXC_BAD_ACCESS (SwiftVLC #20).
|
// Drawable los vóór stop — voorkomt crash als SwiftUI de UIView al vrijgeeft.
|
||||||
if wasPip {
|
mediaPlayer.drawable = nil
|
||||||
|
drawableHost.detachVideo()
|
||||||
|
attachedView = nil
|
||||||
|
plainVideoView = nil
|
||||||
|
|
||||||
|
if pipActive {
|
||||||
pipWindow?.stopPictureInPicture()
|
pipWindow?.stopPictureInPicture()
|
||||||
|
}
|
||||||
|
pipWindow = nil
|
||||||
|
pipPossible = false
|
||||||
pipActive = false
|
pipActive = false
|
||||||
finishStop(gen: gen, proxyWasActive: proxyWasActive, proxySession: proxySession, delay: 0.85)
|
usePipDrawable = false
|
||||||
return
|
|
||||||
}
|
|
||||||
finishStop(gen: gen, proxyWasActive: proxyWasActive, proxySession: proxySession, delay: 0.05)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finishStop(gen: Int, proxyWasActive: Bool, proxySession: Int, delay: TimeInterval) {
|
mediaPlayer.stop()
|
||||||
let work = { [weak self] in
|
mediaPlayer.media = nil
|
||||||
guard let self else { return }
|
livePlayURL = nil
|
||||||
// Nieuwe play() heeft generation gebumpt of stopping gereset → niet meer tear-down'en.
|
livePlayFormat = nil
|
||||||
guard self.stopGeneration == gen, self.stopping else { return }
|
liveStartedAt = nil
|
||||||
|
liveRetryCount = 0
|
||||||
|
everPlayed = false
|
||||||
|
liveMode = false
|
||||||
|
|
||||||
self.pipWindow = nil
|
if proxyWasActive {
|
||||||
self.pipActive = false
|
// Laat in-flight HTTP van VLC even afronden vóór listener-cancel.
|
||||||
self.usePipDrawable = false
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
|
ClearKeyDashProxy.shared.stop()
|
||||||
self.mediaPlayer.stop()
|
|
||||||
self.mediaPlayer.media = nil
|
|
||||||
|
|
||||||
self.mediaPlayer.drawable = nil
|
|
||||||
self.drawableHost.detachVideo()
|
|
||||||
self.attachedView = nil
|
|
||||||
self.plainVideoView = nil
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
|
||||||
guard let self, self.stopGeneration == gen else { return }
|
|
||||||
self.retainedSurface = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
self.livePlayURL = nil
|
|
||||||
self.livePlayFormat = nil
|
|
||||||
self.liveStartedAt = nil
|
|
||||||
self.liveRetryCount = 0
|
|
||||||
self.everPlayed = false
|
|
||||||
self.liveMode = false
|
|
||||||
self.canSeek = false
|
|
||||||
self.didApplyPreferredAudio = false
|
|
||||||
self.knownVodDurationMs = 0
|
|
||||||
self.seekOriginMs = 0
|
|
||||||
self.isPlaying = false
|
|
||||||
self.hasError = false
|
|
||||||
self.errorMessage = nil
|
|
||||||
self.stateText = "Gestopt"
|
|
||||||
self.subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")]
|
|
||||||
self.audioTracks = []
|
|
||||||
|
|
||||||
if proxyWasActive, proxySession > 0 {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
|
||||||
ClearKeyDashProxy.shared.stop(session: proxySession)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
stopping = false
|
||||||
if delay > 0 {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
||||||
} else {
|
|
||||||
work()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - VLCMediaPlayerDelegate (VLCKit 4)
|
// MARK: - VLCMediaPlayerDelegate (VLCKit 4)
|
||||||
|
|
||||||
func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) {
|
func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self, !self.stopping else { return }
|
guard let self else { return }
|
||||||
self.isPlaying = self.mediaPlayer.isPlaying
|
self.isPlaying = self.mediaPlayer.isPlaying
|
||||||
if self.pipPossible {
|
if self.pipPossible {
|
||||||
self.invalidatePipPlaybackState()
|
self.invalidatePipPlaybackState()
|
||||||
|
|
@ -828,65 +594,49 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
self.hasError = false
|
self.hasError = false
|
||||||
self.errorMessage = nil
|
self.errorMessage = nil
|
||||||
self.everPlayed = true
|
self.everPlayed = true
|
||||||
// Geen drawable-rebind hier — dat maakte ClearKey audio-only + crash.
|
|
||||||
if !self.liveMode {
|
|
||||||
self.refreshTracks()
|
self.refreshTracks()
|
||||||
self.applySubtitleStyle()
|
self.applySubtitleStyle()
|
||||||
}
|
|
||||||
case .paused: self.stateText = "Pauze"
|
case .paused: self.stateText = "Pauze"
|
||||||
case .stopped, .stopping:
|
case .stopped, .stopping:
|
||||||
if self.liveMode && !self.everPlayed {
|
if self.clearKeyProxyActive && !self.everPlayed {
|
||||||
self.stateText = "Verbinden…"
|
self.stateText = "Verbinden…"
|
||||||
self.retryLiveIfNeeded()
|
self.retryLiveIfNeeded()
|
||||||
} else if !self.hasError {
|
} else if !self.hasError {
|
||||||
self.stateText = "Gestopt"
|
self.stateText = "Gestopt"
|
||||||
}
|
}
|
||||||
case .error:
|
case .error:
|
||||||
if self.liveMode && !self.everPlayed && self.liveRetryCount < 2 {
|
if self.clearKeyProxyActive && !self.everPlayed && self.liveRetryCount < 2 {
|
||||||
self.stateText = "Opnieuw…"
|
self.stateText = "Opnieuw…"
|
||||||
self.retryLiveIfNeeded()
|
self.retryLiveIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
self.hasError = true
|
self.hasError = true
|
||||||
self.errorMessage = self.clearKeyProxyActive
|
self.errorMessage = self.clearKeyProxyActive
|
||||||
? "Kon deze ClearKey-livezender niet afspelen. Controleer netwerk of probeer opnieuw."
|
? "Kon deze ClearKey-livezender niet afspelen. Controleer netwerk of probeer opnieuw."
|
||||||
: (self.liveMode
|
: "Kon dit bestand niet afspelen"
|
||||||
? "Kon deze livestream niet openen."
|
|
||||||
: "Kon dit bestand niet afspelen")
|
|
||||||
self.stateText = "Fout"
|
self.stateText = "Fout"
|
||||||
}
|
}
|
||||||
case .nothingSpecial:
|
@unknown default: break
|
||||||
break
|
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaPlayerTrackAdded(_ trackId: String, with type: VLCMedia.TrackType) {
|
func mediaPlayerTrackAdded(_ trackId: String, with type: VLCMedia.TrackType) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in self?.refreshTracks() }
|
||||||
guard let self, !self.stopping, !self.liveMode else { return }
|
|
||||||
self.refreshTracks()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaPlayerTrackUpdated(_ trackId: String, with type: VLCMedia.TrackType) {
|
func mediaPlayerTrackUpdated(_ trackId: String, with type: VLCMedia.TrackType) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in self?.refreshTracks() }
|
||||||
guard let self, !self.stopping, !self.liveMode else { return }
|
|
||||||
self.refreshTracks()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaPlayerLengthChanged(_ length: Int64) {
|
func mediaPlayerLengthChanged(_ length: Int64) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self, !self.stopping else { return }
|
self?.invalidatePipPlaybackState()
|
||||||
self.invalidatePipPlaybackState()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaDidFinishParsing(_ aMedia: VLCMedia) {
|
func mediaDidFinishParsing(_ aMedia: VLCMedia) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self, !self.stopping, !self.liveMode else { return }
|
self?.refreshTracks()
|
||||||
self.refreshTracks()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -900,21 +650,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshTime() {
|
private func refreshTime() {
|
||||||
guard !stopping else { return }
|
let t = mediaPlayer.time.intValue
|
||||||
let t = Int64(mediaPlayer.time.intValue) + seekOriginMs
|
|
||||||
let l = mediaPlayer.media?.length.intValue ?? 0
|
let l = mediaPlayer.media?.length.intValue ?? 0
|
||||||
if knownVodDurationMs > 3000 {
|
|
||||||
lengthMs = Int32(min(knownVodDurationMs, Int64(Int32.max)))
|
|
||||||
position = Float(Double(t) / Double(knownVodDurationMs))
|
|
||||||
} else {
|
|
||||||
if l > 0 { lengthMs = l }
|
if l > 0 { lengthMs = l }
|
||||||
position = Float(mediaPlayer.position)
|
position = Float(mediaPlayer.position)
|
||||||
}
|
timeText = Self.format(ms: t)
|
||||||
timeText = Self.format(ms: Int32(min(max(t, 0), Int64(Int32.max))))
|
|
||||||
lengthText = Self.format(ms: lengthMs)
|
lengthText = Self.format(ms: lengthMs)
|
||||||
isPlaying = mediaPlayer.isPlaying
|
isPlaying = mediaPlayer.isPlaying
|
||||||
let seekable = (knownVodDurationMs > 3000 || mediaPlayer.isSeekable) && lengthMs > 3000
|
|
||||||
if seekable != canSeek { canSeek = seekable }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func format(ms: Int32) -> String {
|
private static func format(ms: Int32) -> String {
|
||||||
|
|
@ -929,39 +671,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
// MARK: - PiP / audio
|
// MARK: - PiP / audio
|
||||||
|
|
||||||
fileprivate func onPictureInPictureReady(_ controller: VLCPictureInPictureWindowControlling) {
|
fileprivate func onPictureInPictureReady(_ controller: VLCPictureInPictureWindowControlling) {
|
||||||
guard !stopping, !liveMode, !clearKeyProxyActive else { return }
|
|
||||||
pipWindow = controller
|
pipWindow = controller
|
||||||
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
|
pipPossible = true
|
||||||
controller.stateChangeEventHandler = { [weak self] isStarted in
|
controller.stateChangeEventHandler = { [weak self] isStarted in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if self.stopping {
|
|
||||||
self.pipActive = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let wasActive = self.pipActive
|
|
||||||
self.pipActive = isStarted
|
self.pipActive = isStarted
|
||||||
if wasActive && !isStarted {
|
if !isStarted {
|
||||||
// PiP-X gesloten: herkoppel video-subview aan inline surface (geen drawable-reset).
|
self.restorePlainDrawableAfterPip()
|
||||||
if let view = self.plainVideoView ?? self.retainedSurface {
|
|
||||||
self.drawableHost.attach(to: view)
|
|
||||||
}
|
|
||||||
// Opnieuw FreeType/subs activeren na terugkeer uit PiP.
|
|
||||||
if self.currentSubtitleId != PlayerTrack.offId {
|
|
||||||
self.selectSubtitle(id: self.currentSubtitleId)
|
|
||||||
}
|
|
||||||
self.applySubtitleStyle()
|
|
||||||
self.invalidatePipPlaybackState()
|
|
||||||
}
|
|
||||||
if !wasActive && isStarted {
|
|
||||||
// Net PiP gestart: forceer spu opnieuw (sample-buffer kan spu laten vallen).
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
|
|
||||||
guard let self, self.pipActive, !self.stopping else { return }
|
|
||||||
if self.currentSubtitleId != PlayerTrack.offId {
|
|
||||||
self.selectSubtitle(id: self.currentSubtitleId)
|
|
||||||
}
|
|
||||||
self.applySubtitleStyle()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1016,11 +733,6 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab
|
||||||
private weak var container: UIView?
|
private weak var container: UIView?
|
||||||
private var videoSubview: UIView?
|
private var videoSubview: UIView?
|
||||||
|
|
||||||
var hasVideoSubview: Bool {
|
|
||||||
guard let videoSubview else { return false }
|
|
||||||
return videoSubview.superview != nil && videoSubview.bounds.width > 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func attach(to view: UIView) {
|
func attach(to view: UIView) {
|
||||||
container = view
|
container = view
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
|
|
@ -1098,35 +810,17 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UIKit surface met layout-callback — voorkomt zero-bounds drawable (audio-only).
|
|
||||||
final class VLCSurfaceView: UIView {
|
|
||||||
var onLayout: ((UIView) -> Void)?
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
onLayout?(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VLCVideoView: UIViewRepresentable {
|
struct VLCVideoView: UIViewRepresentable {
|
||||||
let controller: VLCPlayerController
|
let controller: VLCPlayerController
|
||||||
|
|
||||||
func makeUIView(context: Context) -> VLCSurfaceView {
|
func makeUIView(context: Context) -> UIView {
|
||||||
let view = VLCSurfaceView()
|
let view = UIView()
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
view.clipsToBounds = true
|
|
||||||
view.onLayout = { [weak controller] surface in
|
|
||||||
controller?.surfaceDidLayout(surface)
|
|
||||||
}
|
|
||||||
controller.attach(to: view)
|
controller.attach(to: view)
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: VLCSurfaceView, context: Context) {
|
func updateUIView(_ uiView: UIView, context: Context) {
|
||||||
uiView.onLayout = { [weak controller] surface in
|
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout / audio-only).
|
||||||
controller?.surfaceDidLayout(surface)
|
|
||||||
}
|
|
||||||
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout).
|
|
||||||
controller.attach(to: uiView)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,7 @@ struct MovieDetailScreen: View {
|
||||||
movieId: movie.id,
|
movieId: movie.id,
|
||||||
episodeId: nil,
|
episodeId: nil,
|
||||||
seriesId: nil,
|
seriesId: nil,
|
||||||
startMs: 0,
|
startMs: 0
|
||||||
logoUrl: movie.logoUrl
|
|
||||||
)
|
)
|
||||||
.environmentObject(app)
|
.environmentObject(app)
|
||||||
}
|
}
|
||||||
|
|
@ -228,8 +227,7 @@ struct SeriesDetailScreen: View {
|
||||||
movieId: nil,
|
movieId: nil,
|
||||||
episodeId: target.episodeId,
|
episodeId: target.episodeId,
|
||||||
seriesId: seriesId,
|
seriesId: seriesId,
|
||||||
startMs: target.startMs,
|
startMs: target.startMs
|
||||||
logoUrl: series.logoUrl
|
|
||||||
)
|
)
|
||||||
.environmentObject(app)
|
.environmentObject(app)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -19,7 +19,6 @@ struct EventsScreen: View {
|
||||||
@EnvironmentObject private var app: AppModel
|
@EnvironmentObject private var app: AppModel
|
||||||
|
|
||||||
@State private var events: [ScheduleEvent] = []
|
@State private var events: [ScheduleEvent] = []
|
||||||
@State private var hubs: [EventHub] = []
|
|
||||||
@State private var loading = true
|
@State private var loading = true
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var sportFilter: String?
|
@State private var sportFilter: String?
|
||||||
|
|
@ -94,10 +93,6 @@ struct EventsScreen: View {
|
||||||
EventDetailScreen(eventId: eventId)
|
EventDetailScreen(eventId: eventId)
|
||||||
.environmentObject(app)
|
.environmentObject(app)
|
||||||
}
|
}
|
||||||
.navigationDestination(for: EventHubRoute.self) { route in
|
|
||||||
EventHubDestination(route: route)
|
|
||||||
.environmentObject(app)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.task { await refresh() }
|
.task { await refresh() }
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|
@ -154,9 +149,6 @@ struct EventsScreen: View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 22) {
|
VStack(alignment: .leading, spacing: 22) {
|
||||||
headerBlock
|
headerBlock
|
||||||
if !hubs.isEmpty {
|
|
||||||
hubRow
|
|
||||||
}
|
|
||||||
filterChips
|
filterChips
|
||||||
if filtered.isEmpty {
|
if filtered.isEmpty {
|
||||||
emptyBlock
|
emptyBlock
|
||||||
|
|
@ -223,20 +215,6 @@ struct EventsScreen: View {
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hubRow: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
ForEach(hubs) { hub in
|
|
||||||
if let route = EventHubRoute.forHub(hub.id) {
|
|
||||||
NavigationLink(value: route) {
|
|
||||||
EventHubEntryCard(hub: hub)
|
|
||||||
}
|
|
||||||
.buttonStyle(HubPressStyle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var subtitleLine: String {
|
private var subtitleLine: String {
|
||||||
if let sportFilter {
|
if let sportFilter {
|
||||||
return EventSport.label(for: sportFilter)
|
return EventSport.label(for: sportFilter)
|
||||||
|
|
@ -381,9 +359,6 @@ struct EventsScreen: View {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let list = try? await app.api.eventHubs() {
|
|
||||||
hubs = list.filter { EventHubRoute.forHub($0.id) != nil }
|
|
||||||
}
|
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -637,8 +612,7 @@ struct EventDetailScreen: View {
|
||||||
drm: item.info.drm,
|
drm: item.info.drm,
|
||||||
watchSessionId: item.info.watchSessionId,
|
watchSessionId: item.info.watchSessionId,
|
||||||
watchKind: "event",
|
watchKind: "event",
|
||||||
watchId: eventId,
|
watchId: eventId
|
||||||
logoUrl: item.info.logoUrl ?? event?.logoUrl
|
|
||||||
))
|
))
|
||||||
.environmentObject(app)
|
.environmentObject(app)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,7 @@ struct LiveTvScreen: View {
|
||||||
drm: item.info.drm,
|
drm: item.info.drm,
|
||||||
watchSessionId: item.info.watchSessionId,
|
watchSessionId: item.info.watchSessionId,
|
||||||
watchKind: kind,
|
watchKind: kind,
|
||||||
watchId: item.info.streamId,
|
watchId: item.info.streamId
|
||||||
logoUrl: item.info.logoUrl
|
|
||||||
))
|
))
|
||||||
.environmentObject(app)
|
.environmentObject(app)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ struct SettingsScreen: View {
|
||||||
|
|
||||||
Section("Info") {
|
Section("Info") {
|
||||||
LabeledContent("App", value: "VMC iOS")
|
LabeledContent("App", value: "VMC iOS")
|
||||||
LabeledContent("Versie", value: "0.5.49")
|
LabeledContent("Versie", value: "0.5.6")
|
||||||
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
|
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
|
||||||
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
|
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -642,9 +642,9 @@ export function registerViewerRoutes(
|
||||||
|
|
||||||
app.get("/api/v1/client/movies/:id", async (request) => {
|
app.get("/api/v1/client/movies/:id", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
const profileId = requireProfile(auth);
|
requireProfile(auth);
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
return viewers.movieDetail(id, auth.profile?.isKids === true, profileId);
|
return viewers.movieDetail(id, auth.profile?.isKids === true);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/v1/client/series/:id", async (request) => {
|
app.get("/api/v1/client/series/:id", async (request) => {
|
||||||
|
|
@ -684,12 +684,8 @@ export function registerViewerRoutes(
|
||||||
hiddenFromContinue?: boolean;
|
hiddenFromContinue?: boolean;
|
||||||
};
|
};
|
||||||
if (!body.mediaType) throw new AppError("INVALID_REQUEST", "mediaType verplicht", 400);
|
if (!body.mediaType) throw new AppError("INVALID_REQUEST", "mediaType verplicht", 400);
|
||||||
const mediaType = String(body.mediaType).trim().toUpperCase();
|
|
||||||
if (mediaType !== "MOVIE" && mediaType !== "EPISODE") {
|
|
||||||
throw new AppError("INVALID_REQUEST", "mediaType moet MOVIE of EPISODE zijn", 400);
|
|
||||||
}
|
|
||||||
const row = await viewers.upsertProgress(profileId, {
|
const row = await viewers.upsertProgress(profileId, {
|
||||||
mediaType: mediaType as "MOVIE" | "EPISODE",
|
mediaType: body.mediaType,
|
||||||
movieId: body.movieId,
|
movieId: body.movieId,
|
||||||
episodeId: body.episodeId,
|
episodeId: body.episodeId,
|
||||||
seriesId: body.seriesId,
|
seriesId: body.seriesId,
|
||||||
|
|
|
||||||
|
|
@ -2026,17 +2026,13 @@ export class ViewerService {
|
||||||
hiddenFromContinue?: boolean;
|
hiddenFromContinue?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const mediaType = String(body.mediaType || "").trim().toUpperCase();
|
|
||||||
if (mediaType !== "MOVIE" && mediaType !== "EPISODE") {
|
|
||||||
throw new AppError("INVALID_REQUEST", "mediaType moet MOVIE of EPISODE zijn", 400);
|
|
||||||
}
|
|
||||||
const positionMs = Math.max(0, Math.floor(body.positionMs || 0));
|
const positionMs = Math.max(0, Math.floor(body.positionMs || 0));
|
||||||
const durationMs = Math.max(0, Math.floor(body.durationMs || 0));
|
const durationMs = Math.max(0, Math.floor(body.durationMs || 0));
|
||||||
const autoComplete =
|
const autoComplete =
|
||||||
body.completed === true ||
|
body.completed === true ||
|
||||||
(durationMs > 0 && positionMs / durationMs >= 0.9);
|
(durationMs > 0 && positionMs / durationMs >= 0.9);
|
||||||
|
|
||||||
if (mediaType === "MOVIE") {
|
if (body.mediaType === "MOVIE") {
|
||||||
if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400);
|
if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400);
|
||||||
const existingMovie = await prisma.watchProgress.findUnique({
|
const existingMovie = await prisma.watchProgress.findUnique({
|
||||||
where: { profileId_movieId: { profileId, movieId: body.movieId } },
|
where: { profileId_movieId: { profileId, movieId: body.movieId } },
|
||||||
|
|
@ -2527,7 +2523,7 @@ export class ViewerService {
|
||||||
return this.getShelfPrefs(profileId);
|
return this.getShelfPrefs(profileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async movieDetail(movieId: string, kidsMode = false, profileId?: string | null) {
|
async movieDetail(movieId: string, kidsMode = false) {
|
||||||
const movie = await prisma.movie.findUnique({
|
const movie = await prisma.movie.findUnique({
|
||||||
where: { id: movieId },
|
where: { id: movieId },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -2556,12 +2552,6 @@ export class ViewerService {
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = profileId
|
|
||||||
? await prisma.watchProgress.findUnique({
|
|
||||||
where: { profileId_movieId: { profileId, movieId: movie.id } },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "movie" as const,
|
type: "movie" as const,
|
||||||
id: movie.id,
|
id: movie.id,
|
||||||
|
|
@ -2577,10 +2567,6 @@ export class ViewerService {
|
||||||
cast: extras.cast ?? [],
|
cast: extras.cast ?? [],
|
||||||
director: extras.director ?? [],
|
director: extras.director ?? [],
|
||||||
trailerYoutubeKey: extras.trailers?.[0]?.source ?? null,
|
trailerYoutubeKey: extras.trailers?.[0]?.source ?? null,
|
||||||
positionMs: progress && !progress.completed ? progress.positionMs : 0,
|
|
||||||
durationMs: progress?.durationMs ?? 0,
|
|
||||||
completed: progress?.completed === true,
|
|
||||||
progressId: progress?.id ?? null,
|
|
||||||
mediaFiles: movie.mediaFiles.map((f, index) => ({
|
mediaFiles: movie.mediaFiles.map((f, index) => ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
resolution: cleanMeta(f.resolution),
|
resolution: cleanMeta(f.resolution),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue