Fix iOS Live TV streamId String decode and add Events + ClearKey path.
Restore channel list decoding for string/custom IDs, URL-encode play paths, parse DRM/heartbeat, and ship Events tab (0.5.0/12) with best-effort VLC ClearKey.
This commit is contained in:
parent
9b6f8155d0
commit
3e2549c33a
16 changed files with 1402 additions and 50 deletions
|
|
@ -41,3 +41,11 @@ open VMCTV.xcodeproj
|
||||||
## Let op
|
## Let op
|
||||||
|
|
||||||
`Vendor/MobileVLCKit.xcframework` en `Vendor/TVVLCKit.xcframework` staan niet in git (te groot). Altijd op de Mac via de scripts installeren.
|
`Vendor/MobileVLCKit.xcframework` en `Vendor/TVVLCKit.xcframework` staan niet in git (te groot). Altijd op de Mac via de scripts installeren.
|
||||||
|
|
||||||
|
## Live TV / Events / ClearKey (0.5.x)
|
||||||
|
|
||||||
|
- `streamId` is een **String** (`"123"` of `"custom:uuid"`); path-segmenten worden URL-geëncodeerd (inclusief `:`).
|
||||||
|
- Play-response parseert `format`, `drm` (ClearKey kids/keys) en `watchSessionId`.
|
||||||
|
- Heartbeat: `POST /api/v1/client/watching/heartbeat` ~elke 25s bij custom live + events.
|
||||||
|
- **ClearKey/DASH:** best-effort via VLC media options (`:demux=dash`, `:cenc-key=…`). MobileVLCKit heeft geen officiële ClearKey-API zoals ExoPlayer; Xtream HLS/TS speelt normaal. Als decryptie faalt toont de app: *“Deze stream vereist DRM-ondersteuning die binnenkort komt”*.
|
||||||
|
- Events-tab verschijnt als `/me` → `events.enabled` true is.
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
CA1BAD3B015E434C85D51CD2 /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F950D46F720C4C359184B2CF /* SearchScreen.swift */; };
|
CA1BAD3B015E434C85D51CD2 /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F950D46F720C4C359184B2CF /* SearchScreen.swift */; };
|
||||||
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 */; };
|
||||||
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 */; };
|
||||||
|
|
@ -57,6 +58,7 @@
|
||||||
F950D46F720C4C359184B2CF /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = "<group>"; };
|
F950D46F720C4C359184B2CF /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
|
|
@ -168,6 +170,7 @@
|
||||||
F0A435950EF040DE83F30779 /* DetailScreens.swift */,
|
F0A435950EF040DE83F30779 /* DetailScreens.swift */,
|
||||||
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */,
|
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */,
|
||||||
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
|
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
|
||||||
|
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */,
|
||||||
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
|
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
|
||||||
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */,
|
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */,
|
||||||
);
|
);
|
||||||
|
|
@ -267,6 +270,7 @@
|
||||||
668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */,
|
668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */,
|
||||||
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 */,
|
||||||
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
|
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
|
||||||
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */,
|
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|
@ -317,7 +321,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 = 11;
|
CURRENT_PROJECT_VERSION = 12;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -332,7 +336,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.4.7;
|
MARKETING_VERSION = 0.5.0;
|
||||||
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)";
|
||||||
|
|
@ -350,7 +354,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 = 11;
|
CURRENT_PROJECT_VERSION = 12;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -365,7 +369,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.4.7;
|
MARKETING_VERSION = 0.5.0;
|
||||||
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)";
|
||||||
|
|
|
||||||
|
|
@ -303,8 +303,52 @@ actor ApiClient {
|
||||||
return wrap.channels
|
return wrap.channels
|
||||||
}
|
}
|
||||||
|
|
||||||
func iptvPlay(streamId: Int) async throws -> IptvPlayInfo {
|
func iptvPlay(streamId: String) async throws -> IptvPlayInfo {
|
||||||
try await get("/api/v1/client/iptv/channels/\(streamId)/play")
|
let enc = Self.encodePathSegment(streamId)
|
||||||
|
return try await get("/api/v1/client/iptv/channels/\(enc)/play")
|
||||||
|
}
|
||||||
|
|
||||||
|
func iptvEpg(streamId: String) async throws -> IptvEpg {
|
||||||
|
let enc = Self.encodePathSegment(streamId)
|
||||||
|
return try await get("/api/v1/client/iptv/channels/\(enc)/epg")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Presence-heartbeat voor custom live-lists / events (best-effort).
|
||||||
|
func watchingHeartbeat(watchSessionId: String? = nil, kind: String? = nil, id: String? = nil) async {
|
||||||
|
var body: [String: Any] = [:]
|
||||||
|
if let watchSessionId, !watchSessionId.isEmpty { body["watchSessionId"] = watchSessionId }
|
||||||
|
if let kind, !kind.isEmpty { body["kind"] = kind }
|
||||||
|
if let id, !id.isEmpty { body["id"] = id }
|
||||||
|
guard !body.isEmpty else { return }
|
||||||
|
_ = try? await requestData(
|
||||||
|
"/api/v1/client/watching/heartbeat",
|
||||||
|
method: "POST",
|
||||||
|
json: body,
|
||||||
|
authed: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listScheduleEvents() async throws -> ScheduleEventsPayload {
|
||||||
|
try await get("/api/v1/client/events")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getScheduleEvent(eventId: String) async throws -> ScheduleEvent {
|
||||||
|
let enc = Self.encodePathSegment(eventId)
|
||||||
|
struct Wrap: Decodable { let event: ScheduleEvent }
|
||||||
|
let wrap: Wrap = try await get("/api/v1/client/events/\(enc)")
|
||||||
|
return wrap.event
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleEventPlay(eventId: String) async throws -> IptvPlayInfo {
|
||||||
|
let enc = Self.encodePathSegment(eventId)
|
||||||
|
return try await get("/api/v1/client/events/\(enc)/play")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode path segment (incl. `:` in `custom:uuid`) — strenger dan urlPathAllowed.
|
||||||
|
private static func encodePathSegment(_ raw: String) -> String {
|
||||||
|
var allowed = CharacterSet.alphanumerics
|
||||||
|
allowed.insert(charactersIn: "-._~")
|
||||||
|
return raw.addingPercentEncoding(withAllowedCharacters: allowed) ?? raw
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - HTTP
|
// MARK: - HTTP
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ final class AppModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MainTab: String, CaseIterable, Identifiable {
|
enum MainTab: String, CaseIterable, Identifiable {
|
||||||
case home, movies, series, tv, search, account
|
case home, movies, series, tv, events, search, account
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
|
|
@ -24,6 +24,7 @@ final class AppModel: ObservableObject {
|
||||||
case .movies: return "Films"
|
case .movies: return "Films"
|
||||||
case .series: return "Series"
|
case .series: return "Series"
|
||||||
case .tv: return "TV"
|
case .tv: return "TV"
|
||||||
|
case .events: return "Events"
|
||||||
case .search: return "Zoeken"
|
case .search: return "Zoeken"
|
||||||
case .account: return "Account"
|
case .account: return "Account"
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +36,7 @@ final class AppModel: ObservableObject {
|
||||||
case .movies: return "film"
|
case .movies: return "film"
|
||||||
case .series: return "rectangle.stack.fill"
|
case .series: return "rectangle.stack.fill"
|
||||||
case .tv: return "tv.fill"
|
case .tv: return "tv.fill"
|
||||||
|
case .events: return "sportscourt.fill"
|
||||||
case .search: return "magnifyingglass"
|
case .search: return "magnifyingglass"
|
||||||
case .account: return "person.crop.circle.fill"
|
case .account: return "person.crop.circle.fill"
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +50,7 @@ final class AppModel: ObservableObject {
|
||||||
@Published var phase: Phase = .boot
|
@Published var phase: Phase = .boot
|
||||||
@Published var tab: MainTab = .home
|
@Published var tab: MainTab = .home
|
||||||
@Published var iptvEnabled = false
|
@Published var iptvEnabled = false
|
||||||
|
@Published var eventsEnabled = false
|
||||||
@Published var canManageProfiles = false
|
@Published var canManageProfiles = false
|
||||||
@Published var bootError: String?
|
@Published var bootError: String?
|
||||||
/// Code van QR/deep link om een ander apparaat goed te keuren (hoofdprofiel).
|
/// Code van QR/deep link om een ander apparaat goed te keuren (hoofdprofiel).
|
||||||
|
|
@ -81,6 +84,7 @@ final class AppModel: ObservableObject {
|
||||||
do {
|
do {
|
||||||
let me = try await api.clientMe()
|
let me = try await api.clientMe()
|
||||||
iptvEnabled = me.iptvEnabled
|
iptvEnabled = me.iptvEnabled
|
||||||
|
eventsEnabled = me.eventsEnabled
|
||||||
if let profile = me.profile {
|
if let profile = me.profile {
|
||||||
store.setProfile(
|
store.setProfile(
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
|
|
@ -118,6 +122,7 @@ final class AppModel: ObservableObject {
|
||||||
store.setProfile(id: nil, name: nil, avatarId: nil)
|
store.setProfile(id: nil, name: nil, avatarId: nil)
|
||||||
if let me = try? await api.clientMe() {
|
if let me = try? await api.clientMe() {
|
||||||
iptvEnabled = me.iptvEnabled
|
iptvEnabled = me.iptvEnabled
|
||||||
|
eventsEnabled = me.eventsEnabled
|
||||||
}
|
}
|
||||||
phase = .profiles
|
phase = .profiles
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +137,10 @@ final class AppModel: ObservableObject {
|
||||||
)
|
)
|
||||||
favorites.bind(profileId: profile.id)
|
favorites.bind(profileId: profile.id)
|
||||||
Task {
|
Task {
|
||||||
|
if let me = try? await api.clientMe() {
|
||||||
|
iptvEnabled = me.iptvEnabled
|
||||||
|
eventsEnabled = me.eventsEnabled
|
||||||
|
}
|
||||||
await favorites.refresh(using: api)
|
await favorites.refresh(using: api)
|
||||||
if let payload = try? await api.listProfiles() {
|
if let payload = try? await api.listProfiles() {
|
||||||
canManageProfiles = payload.canManage == true
|
canManageProfiles = payload.canManage == true
|
||||||
|
|
@ -155,6 +164,7 @@ final class AppModel: ObservableObject {
|
||||||
await api.configure(baseUrl: store.apiBase, token: nil)
|
await api.configure(baseUrl: store.apiBase, token: nil)
|
||||||
favorites.bind(profileId: nil)
|
favorites.bind(profileId: nil)
|
||||||
iptvEnabled = false
|
iptvEnabled = false
|
||||||
|
eventsEnabled = false
|
||||||
pendingApproveCode = nil
|
pendingApproveCode = nil
|
||||||
phase = .server
|
phase = .server
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,14 @@ struct ProfilesPayload: Decodable {
|
||||||
|
|
||||||
struct ClientMe: Decodable {
|
struct ClientMe: Decodable {
|
||||||
struct Iptv: Decodable { let enabled: Bool? }
|
struct Iptv: Decodable { let enabled: Bool? }
|
||||||
|
struct Events: Decodable { let enabled: Bool? }
|
||||||
let iptv: Iptv?
|
let iptv: Iptv?
|
||||||
|
let events: Events?
|
||||||
let profile: ViewerProfile?
|
let profile: ViewerProfile?
|
||||||
let deviceId: String?
|
let deviceId: String?
|
||||||
|
|
||||||
var iptvEnabled: Bool { iptv?.enabled == true }
|
var iptvEnabled: Bool { iptv?.enabled == true }
|
||||||
|
var eventsEnabled: Bool { events?.enabled == true }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LinkedDevice: Decodable, Identifiable, Hashable {
|
struct LinkedDevice: Decodable, Identifiable, Hashable {
|
||||||
|
|
@ -239,23 +242,290 @@ struct IptvCategory: Decodable, Identifiable, Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct IptvChannel: Decodable, Identifiable, Hashable {
|
struct IptvChannel: Decodable, Identifiable, Hashable {
|
||||||
var id: Int { streamId }
|
var id: String { streamId }
|
||||||
let streamId: Int
|
let streamId: String
|
||||||
let name: String
|
let name: String
|
||||||
let logoUrl: String?
|
let logoUrl: String?
|
||||||
let number: Int?
|
let number: Int?
|
||||||
let categoryId: String?
|
let categoryId: String?
|
||||||
let nowTitle: String?
|
let nowTitle: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case streamId, name, logoUrl, number, categoryId, nowTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
streamId = try decodeFlexibleString(c, forKey: .streamId)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
logoUrl = try c.decodeIfPresent(String.self, forKey: .logoUrl)
|
||||||
|
number = try c.decodeIfPresent(Int.self, forKey: .number)
|
||||||
|
categoryId = try c.decodeIfPresent(String.self, forKey: .categoryId)
|
||||||
|
nowTitle = try c.decodeIfPresent(String.self, forKey: .nowTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClearKeyEntry: Decodable, Hashable {
|
||||||
|
let kid: String
|
||||||
|
let key: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DrmInfo: Decodable, Hashable {
|
||||||
|
let type: String
|
||||||
|
let keys: [ClearKeyEntry]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey { case type, keys }
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
type = try c.decodeIfPresent(String.self, forKey: .type) ?? "clearkey"
|
||||||
|
keys = try c.decodeIfPresent([ClearKeyEntry].self, forKey: .keys) ?? []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct IptvPlayInfo: Decodable {
|
struct IptvPlayInfo: Decodable {
|
||||||
let streamId: Int
|
let streamId: String
|
||||||
let name: String
|
let name: String
|
||||||
let logoUrl: String?
|
let logoUrl: String?
|
||||||
let streamUrl: String
|
let streamUrl: String
|
||||||
let format: String?
|
let format: String?
|
||||||
let fallbackStreamUrl: String?
|
let fallbackStreamUrl: String?
|
||||||
let fallbackFormat: String?
|
let fallbackFormat: String?
|
||||||
|
let drm: DrmInfo?
|
||||||
|
let watchSessionId: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case streamId, name, logoUrl, streamUrl, format
|
||||||
|
case fallbackStreamUrl, fallbackFormat, drm, watchSessionId
|
||||||
|
case eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
if let sid = try? decodeFlexibleString(c, forKey: .streamId) {
|
||||||
|
streamId = sid
|
||||||
|
} else if let eid = try c.decodeIfPresent(String.self, forKey: .eventId) {
|
||||||
|
streamId = eid
|
||||||
|
} else {
|
||||||
|
streamId = ""
|
||||||
|
}
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
logoUrl = try c.decodeIfPresent(String.self, forKey: .logoUrl)
|
||||||
|
streamUrl = try c.decode(String.self, forKey: .streamUrl)
|
||||||
|
format = try c.decodeIfPresent(String.self, forKey: .format)
|
||||||
|
fallbackStreamUrl = try c.decodeIfPresent(String.self, forKey: .fallbackStreamUrl)
|
||||||
|
fallbackFormat = try c.decodeIfPresent(String.self, forKey: .fallbackFormat)
|
||||||
|
drm = try c.decodeIfPresent(DrmInfo.self, forKey: .drm)
|
||||||
|
watchSessionId = try c.decodeIfPresent(String.self, forKey: .watchSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var clearKeyEntries: [ClearKeyEntry] {
|
||||||
|
guard let drm, drm.type.lowercased() == "clearkey", !drm.keys.isEmpty else { return [] }
|
||||||
|
return drm.keys
|
||||||
|
}
|
||||||
|
|
||||||
|
var needsClearKey: Bool { !clearKeyEntries.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EpgProgram: Decodable, Hashable {
|
||||||
|
let title: String
|
||||||
|
let start: String?
|
||||||
|
let end: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IptvEpg: Decodable, Hashable {
|
||||||
|
let streamId: String
|
||||||
|
let channelName: String
|
||||||
|
let logoUrl: String?
|
||||||
|
let now: EpgProgram?
|
||||||
|
let next: EpgProgram?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case streamId, channelName, logoUrl, now, next
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
streamId = (try? decodeFlexibleString(c, forKey: .streamId)) ?? ""
|
||||||
|
channelName = try c.decode(String.self, forKey: .channelName)
|
||||||
|
logoUrl = try c.decodeIfPresent(String.self, forKey: .logoUrl)
|
||||||
|
now = try c.decodeIfPresent(EpgProgram.self, forKey: .now)
|
||||||
|
next = try c.decodeIfPresent(EpgProgram.self, forKey: .next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScheduleEventsPayload: Decodable {
|
||||||
|
let generatedAt: String?
|
||||||
|
let events: [ScheduleEvent]
|
||||||
|
let providers: [String]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case generatedAt, events, providers
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
generatedAt = try c.decodeIfPresent(String.self, forKey: .generatedAt)
|
||||||
|
events = try c.decodeIfPresent([ScheduleEvent].self, forKey: .events) ?? []
|
||||||
|
providers = try c.decodeIfPresent([String].self, forKey: .providers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScheduleEvent: Decodable, Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let provider: String
|
||||||
|
let category: String?
|
||||||
|
let eventType: String?
|
||||||
|
let subtitle: String?
|
||||||
|
let channel: String?
|
||||||
|
let network: String?
|
||||||
|
let isLive: Bool?
|
||||||
|
let phase: String?
|
||||||
|
let playable: Bool?
|
||||||
|
let status: String?
|
||||||
|
let start: String
|
||||||
|
let stop: String
|
||||||
|
let startLocal: String?
|
||||||
|
let stopLocal: String?
|
||||||
|
let imageLandscape: String?
|
||||||
|
let imageBackdrop: String?
|
||||||
|
let imagePortrait: String?
|
||||||
|
let logoUrl: String?
|
||||||
|
let images: [String]?
|
||||||
|
let audioLanguages: [String]?
|
||||||
|
|
||||||
|
var isPlayable: Bool { playable == true }
|
||||||
|
var phaseValue: String { (phase ?? "upcoming").lowercased() }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, description, provider, category, eventType, subtitle
|
||||||
|
case channel, network, isLive, phase, playable, status
|
||||||
|
case start, stop, startLocal, stopLocal
|
||||||
|
case imageLandscape, imageBackdrop, imagePortrait, logoUrl, images, audioLanguages
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
description = try c.decodeIfPresent(String.self, forKey: .description)
|
||||||
|
provider = try c.decodeIfPresent(String.self, forKey: .provider) ?? "unknown"
|
||||||
|
category = try c.decodeIfPresent(String.self, forKey: .category)
|
||||||
|
eventType = try c.decodeIfPresent(String.self, forKey: .eventType)
|
||||||
|
subtitle = try c.decodeIfPresent(String.self, forKey: .subtitle)
|
||||||
|
channel = try c.decodeIfPresent(String.self, forKey: .channel)
|
||||||
|
network = try c.decodeIfPresent(String.self, forKey: .network)
|
||||||
|
isLive = try c.decodeIfPresent(Bool.self, forKey: .isLive)
|
||||||
|
phase = try c.decodeIfPresent(String.self, forKey: .phase)
|
||||||
|
playable = try c.decodeIfPresent(Bool.self, forKey: .playable)
|
||||||
|
status = try c.decodeIfPresent(String.self, forKey: .status)
|
||||||
|
start = try c.decodeIfPresent(String.self, forKey: .start) ?? ""
|
||||||
|
stop = try c.decodeIfPresent(String.self, forKey: .stop) ?? ""
|
||||||
|
startLocal = try c.decodeIfPresent(String.self, forKey: .startLocal)
|
||||||
|
stopLocal = try c.decodeIfPresent(String.self, forKey: .stopLocal)
|
||||||
|
imageLandscape = try c.decodeIfPresent(String.self, forKey: .imageLandscape)
|
||||||
|
imageBackdrop = try c.decodeIfPresent(String.self, forKey: .imageBackdrop)
|
||||||
|
imagePortrait = try c.decodeIfPresent(String.self, forKey: .imagePortrait)
|
||||||
|
logoUrl = try c.decodeIfPresent(String.self, forKey: .logoUrl)
|
||||||
|
images = try c.decodeIfPresent([String].self, forKey: .images)
|
||||||
|
audioLanguages = try c.decodeIfPresent([String].self, forKey: .audioLanguages)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
description: String?,
|
||||||
|
provider: String,
|
||||||
|
category: String?,
|
||||||
|
eventType: String?,
|
||||||
|
subtitle: String?,
|
||||||
|
channel: String?,
|
||||||
|
network: String?,
|
||||||
|
isLive: Bool?,
|
||||||
|
phase: String?,
|
||||||
|
playable: Bool?,
|
||||||
|
status: String?,
|
||||||
|
start: String,
|
||||||
|
stop: String,
|
||||||
|
startLocal: String?,
|
||||||
|
stopLocal: String?,
|
||||||
|
imageLandscape: String?,
|
||||||
|
imageBackdrop: String?,
|
||||||
|
imagePortrait: String?,
|
||||||
|
logoUrl: String?,
|
||||||
|
images: [String]?,
|
||||||
|
audioLanguages: [String]?
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.provider = provider
|
||||||
|
self.category = category
|
||||||
|
self.eventType = eventType
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.channel = channel
|
||||||
|
self.network = network
|
||||||
|
self.isLive = isLive
|
||||||
|
self.phase = phase
|
||||||
|
self.playable = playable
|
||||||
|
self.status = status
|
||||||
|
self.start = start
|
||||||
|
self.stop = stop
|
||||||
|
self.startLocal = startLocal
|
||||||
|
self.stopLocal = stopLocal
|
||||||
|
self.imageLandscape = imageLandscape
|
||||||
|
self.imageBackdrop = imageBackdrop
|
||||||
|
self.imagePortrait = imagePortrait
|
||||||
|
self.logoUrl = logoUrl
|
||||||
|
self.images = images
|
||||||
|
self.audioLanguages = audioLanguages
|
||||||
|
}
|
||||||
|
|
||||||
|
func withImages(
|
||||||
|
landscape: String? = nil,
|
||||||
|
backdrop: String? = nil,
|
||||||
|
portrait: String? = nil,
|
||||||
|
logo: String? = nil,
|
||||||
|
imgs: [String]? = nil,
|
||||||
|
channel: String? = nil,
|
||||||
|
network: String? = nil
|
||||||
|
) -> ScheduleEvent {
|
||||||
|
ScheduleEvent(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
provider: provider,
|
||||||
|
category: category,
|
||||||
|
eventType: eventType,
|
||||||
|
subtitle: subtitle,
|
||||||
|
channel: channel ?? self.channel,
|
||||||
|
network: network ?? self.network,
|
||||||
|
isLive: isLive,
|
||||||
|
phase: phase,
|
||||||
|
playable: playable,
|
||||||
|
status: status,
|
||||||
|
start: start,
|
||||||
|
stop: stop,
|
||||||
|
startLocal: startLocal,
|
||||||
|
stopLocal: stopLocal,
|
||||||
|
imageLandscape: landscape ?? imageLandscape,
|
||||||
|
imageBackdrop: backdrop ?? imageBackdrop,
|
||||||
|
imagePortrait: portrait ?? imagePortrait,
|
||||||
|
logoUrl: logo ?? logoUrl,
|
||||||
|
images: imgs ?? images,
|
||||||
|
audioLanguages: audioLanguages
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API streamId kan String (`"123"` / `"custom:uuid"`) of legacy Int zijn.
|
||||||
|
func decodeFlexibleString<K: CodingKey>(_ c: KeyedDecodingContainer<K>, forKey key: K) throws -> String {
|
||||||
|
if let s = try? c.decode(String.self, forKey: key) { return s }
|
||||||
|
if let i = try? c.decode(Int.self, forKey: key) { return String(i) }
|
||||||
|
if let d = try? c.decode(Double.self, forKey: key) { return String(Int(d)) }
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: key, in: c, debugDescription: "Verwacht string of int id")
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ShelfPref: Codable, Identifiable, Hashable {
|
struct ShelfPref: Codable, Identifiable, Hashable {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ struct PlayRequest: Identifiable {
|
||||||
let isLive: Bool
|
let isLive: Bool
|
||||||
let fallbackUrl: URL?
|
let fallbackUrl: URL?
|
||||||
let subtitleFileURL: URL?
|
let subtitleFileURL: URL?
|
||||||
|
let format: String?
|
||||||
|
let drm: DrmInfo?
|
||||||
|
let watchSessionId: String?
|
||||||
|
let watchKind: String?
|
||||||
|
let watchId: String?
|
||||||
|
|
||||||
static func vod(
|
static func vod(
|
||||||
url: URL,
|
url: URL,
|
||||||
|
|
@ -28,17 +33,34 @@ struct PlayRequest: Identifiable {
|
||||||
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func live(url: URL, title: String, fallback: URL?) -> PlayRequest {
|
static func live(
|
||||||
|
url: URL,
|
||||||
|
title: String,
|
||||||
|
fallback: URL?,
|
||||||
|
format: String? = nil,
|
||||||
|
drm: DrmInfo? = nil,
|
||||||
|
watchSessionId: String? = nil,
|
||||||
|
watchKind: String? = nil,
|
||||||
|
watchId: String? = nil
|
||||||
|
) -> 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,
|
||||||
|
watchKind: watchKind, watchId: watchId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var needsClearKey: Bool {
|
||||||
|
guard let drm, drm.type.lowercased() == "clearkey" else { return false }
|
||||||
|
return !drm.keys.isEmpty
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlayerScreen: View {
|
struct PlayerScreen: View {
|
||||||
|
|
@ -53,6 +75,7 @@ struct PlayerScreen: View {
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var progressTask: Task<Void, Never>?
|
@State private var progressTask: Task<Void, Never>?
|
||||||
@State private var hideTask: Task<Void, Never>?
|
@State private var hideTask: Task<Void, Never>?
|
||||||
|
@State private var heartbeatTask: Task<Void, Never>?
|
||||||
@State private var showTracks = false
|
@State private var showTracks = false
|
||||||
@State private var trackTab = 0
|
@State private var trackTab = 0
|
||||||
@State private var subtitleHits: [SubtitleHit] = []
|
@State private var subtitleHits: [SubtitleHit] = []
|
||||||
|
|
@ -97,11 +120,13 @@ struct PlayerScreen: View {
|
||||||
.task {
|
.task {
|
||||||
await start()
|
await start()
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
|
startHeartbeatIfNeeded()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
app.leavePlayback()
|
app.leavePlayback()
|
||||||
hideTask?.cancel()
|
hideTask?.cancel()
|
||||||
progressTask?.cancel()
|
progressTask?.cancel()
|
||||||
|
heartbeatTask?.cancel()
|
||||||
Task { await saveProgress() }
|
Task { await saveProgress() }
|
||||||
player.stop()
|
player.stop()
|
||||||
}
|
}
|
||||||
|
|
@ -361,16 +386,45 @@ struct PlayerScreen: View {
|
||||||
loading = true
|
loading = true
|
||||||
error = nil
|
error = nil
|
||||||
if request.isLive {
|
if request.isLive {
|
||||||
player.playLive(url: request.url, fallback: request.fallbackUrl)
|
player.playLive(
|
||||||
|
url: request.url,
|
||||||
|
fallback: request.fallbackUrl,
|
||||||
|
format: request.format,
|
||||||
|
clearKeys: request.drm?.keys ?? []
|
||||||
|
)
|
||||||
|
// Geef VLC tijd; bij ClearKey + blijvende fout → duidelijke NL-melding.
|
||||||
|
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
||||||
|
if request.needsClearKey && (player.hasError || !player.isPlaying) {
|
||||||
|
try? await Task.sleep(nanoseconds: 3_500_000_000)
|
||||||
|
if player.hasError || !player.isPlaying {
|
||||||
|
error = "Deze stream vereist DRM-ondersteuning die binnenkort komt"
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
player.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
|
player.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
|
||||||
progressTask = Task { await progressLoop() }
|
progressTask = Task { await progressLoop() }
|
||||||
}
|
}
|
||||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||||
loading = false
|
loading = false
|
||||||
player.refreshTracks()
|
player.refreshTracks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startHeartbeatIfNeeded() {
|
||||||
|
heartbeatTask?.cancel()
|
||||||
|
guard let session = request.watchSessionId, !session.isEmpty else { return }
|
||||||
|
heartbeatTask = Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await app.api.watchingHeartbeat(
|
||||||
|
watchSessionId: session,
|
||||||
|
kind: request.watchKind,
|
||||||
|
id: request.watchId
|
||||||
|
)
|
||||||
|
try? await Task.sleep(nanoseconds: 25_000_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func progressLoop() async {
|
private func progressLoop() async {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(nanoseconds: 15_000_000_000)
|
try? await Task.sleep(nanoseconds: 15_000_000_000)
|
||||||
|
|
|
||||||
|
|
@ -68,16 +68,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func playLive(url: URL, fallback: URL? = nil) {
|
func playLive(
|
||||||
|
url: URL,
|
||||||
|
fallback: URL? = nil,
|
||||||
|
format: String? = nil,
|
||||||
|
clearKeys: [ClearKeyEntry] = []
|
||||||
|
) {
|
||||||
hasError = false
|
hasError = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
stateText = "Live laden…"
|
stateText = "Live laden…"
|
||||||
let media = VLCMedia(url: url)
|
let media = VLCMedia(url: url)
|
||||||
media.delegate = self
|
media.delegate = self
|
||||||
media.addOption(":network-caching=1200")
|
Self.applyLiveOptions(to: media, url: url, format: format, clearKeys: clearKeys)
|
||||||
media.addOption(":http-reconnect")
|
|
||||||
media.addOption(":live-caching=1000")
|
|
||||||
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
|
||||||
mediaPlayer.media = media
|
mediaPlayer.media = media
|
||||||
media.parse()
|
media.parse()
|
||||||
mediaPlayer.play()
|
mediaPlayer.play()
|
||||||
|
|
@ -85,11 +87,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
if let fallback {
|
if let fallback {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
|
||||||
guard let self, !self.isPlaying || self.hasError else { return }
|
guard let self, !self.isPlaying || self.hasError else { return }
|
||||||
|
// Geen ClearKey op fallback tenzij zelfde DRM nodig is — probeer plain HLS/TS.
|
||||||
let fb = VLCMedia(url: fallback)
|
let fb = VLCMedia(url: fallback)
|
||||||
fb.delegate = self
|
fb.delegate = self
|
||||||
fb.addOption(":network-caching=1200")
|
Self.applyLiveOptions(to: fb, url: fallback, format: nil, clearKeys: [])
|
||||||
fb.addOption(":http-reconnect")
|
|
||||||
fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
|
||||||
self.mediaPlayer.media = fb
|
self.mediaPlayer.media = fb
|
||||||
fb.parse()
|
fb.parse()
|
||||||
self.mediaPlayer.play()
|
self.mediaPlayer.play()
|
||||||
|
|
@ -99,6 +100,56 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
startTicker()
|
startTicker()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Best-effort DASH + ClearKey via libvlc media options.
|
||||||
|
/// MobileVLCKit heeft geen officiële ClearKey-API; we zetten gangbare demux/cenc-opties.
|
||||||
|
private static func applyLiveOptions(
|
||||||
|
to media: VLCMedia,
|
||||||
|
url: URL,
|
||||||
|
format: String?,
|
||||||
|
clearKeys: [ClearKeyEntry]
|
||||||
|
) {
|
||||||
|
media.addOption(":network-caching=1200")
|
||||||
|
media.addOption(":http-reconnect")
|
||||||
|
media.addOption(":live-caching=1000")
|
||||||
|
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
|
||||||
|
|
||||||
|
let fmt = (format ?? "").lowercased()
|
||||||
|
let path = url.absoluteString.lowercased()
|
||||||
|
let isDash = fmt == "dash" || path.contains(".mpd")
|
||||||
|
let isHls = fmt == "hls" || path.contains(".m3u8") || path.contains("m3u8")
|
||||||
|
|
||||||
|
if isDash {
|
||||||
|
media.addOption(":demux=dash")
|
||||||
|
media.addOption(":adaptive-logic=highest")
|
||||||
|
media.addOption(":adaptive-use-access")
|
||||||
|
} else if isHls {
|
||||||
|
media.addOption(":demux=hls")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !clearKeys.isEmpty else { return }
|
||||||
|
// Probeer meerdere option-namen die in libvlc/cenc-builds voorkomen.
|
||||||
|
for entry in clearKeys {
|
||||||
|
let kid = sanitizeHex(entry.kid)
|
||||||
|
let key = sanitizeHex(entry.key)
|
||||||
|
guard kid.count == 32, key.count == 32 else { continue }
|
||||||
|
media.addOption(":cenc-key=\(kid):\(key)")
|
||||||
|
media.addOption(":encryption-key=\(key)")
|
||||||
|
// Sommige builds: kid zonder scheiding + key als hex.
|
||||||
|
media.addOption(":demuxdump-key=\(key)")
|
||||||
|
}
|
||||||
|
// Extra hint voor ISO-BMFF CENC in DASH.
|
||||||
|
media.addOption(":no-spu")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitizeHex(_ raw: String) -> String {
|
||||||
|
raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.replacingOccurrences(of: "0x", with: "", options: .caseInsensitive)
|
||||||
|
.replacingOccurrences(of: "-", with: "")
|
||||||
|
.replacingOccurrences(of: ":", with: "")
|
||||||
|
.replacingOccurrences(of: " ", with: "")
|
||||||
|
.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
func togglePlay() {
|
func togglePlay() {
|
||||||
if mediaPlayer.isPlaying { mediaPlayer.pause() }
|
if mediaPlayer.isPlaying { mediaPlayer.pause() }
|
||||||
else { mediaPlayer.play() }
|
else { mediaPlayer.play() }
|
||||||
|
|
|
||||||
803
apps/ios/VMC/UI/EventsScreen.swift
Normal file
803
apps/ios/VMC/UI/EventsScreen.swift
Normal file
|
|
@ -0,0 +1,803 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - List
|
||||||
|
|
||||||
|
struct EventsScreen: View {
|
||||||
|
@EnvironmentObject private var app: AppModel
|
||||||
|
|
||||||
|
@State private var events: [ScheduleEvent] = []
|
||||||
|
@State private var loading = true
|
||||||
|
@State private var refreshing = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var providerFilter: String?
|
||||||
|
@State private var now = Date()
|
||||||
|
@State private var path = NavigationPath()
|
||||||
|
|
||||||
|
private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||||||
|
private let autoRefresh = Timer.publish(every: 75, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
private var filtered: [ScheduleEvent] {
|
||||||
|
guard let providerFilter, !providerFilter.isEmpty else { return events }
|
||||||
|
return events.filter { $0.provider.caseInsensitiveCompare(providerFilter) == .orderedSame }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var liveEvents: [ScheduleEvent] {
|
||||||
|
filtered.filter { EventHelpers.isLive($0, now: now) }
|
||||||
|
.sorted { ($0.isPlayable ? 1 : 0) > ($1.isPlayable ? 1 : 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var upcomingSections: [(String, [ScheduleEvent])] {
|
||||||
|
EventHelpers.groupByDay(filtered).compactMap { label, dayEvents in
|
||||||
|
let upcoming = dayEvents.filter { !EventHelpers.isLive($0, now: now) }
|
||||||
|
return upcoming.isEmpty ? nil : (label, upcoming)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var providers: [String] {
|
||||||
|
Array(Set(events.map(\.provider))).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack(path: $path) {
|
||||||
|
content
|
||||||
|
.background(McColors.bg.ignoresSafeArea())
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
BrandHeader(subtitle: "EVENTS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbarBackground(McColors.bg, for: .navigationBar)
|
||||||
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
|
.navigationDestination(for: String.self) { eventId in
|
||||||
|
EventDetailScreen(eventId: eventId)
|
||||||
|
.environmentObject(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await refresh() }
|
||||||
|
.onReceive(clock) { now = $0 }
|
||||||
|
.onReceive(autoRefresh) { _ in Task { await refresh(silent: true) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if providers.count > 1 {
|
||||||
|
providerChips
|
||||||
|
}
|
||||||
|
|
||||||
|
if loading && events.isEmpty {
|
||||||
|
ProgressView("Events laden…")
|
||||||
|
.tint(.white)
|
||||||
|
.foregroundStyle(McColors.textDim)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if let error, events.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center)
|
||||||
|
Button("Opnieuw") { Task { await refresh() } }
|
||||||
|
.buttonStyle(McPrimaryButtonStyle())
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if filtered.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
providerFilter != nil
|
||||||
|
? "Geen events voor \(EventHelpers.prettyProvider(providerFilter!))"
|
||||||
|
: "Geen events in de komende dagen",
|
||||||
|
systemImage: "calendar"
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
if !liveEvents.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(liveEvents) { ev in
|
||||||
|
Button { path.append(ev.id) } label: {
|
||||||
|
EventRowView(event: ev, now: now)
|
||||||
|
}
|
||||||
|
.listRowBackground(McColors.bgElevated)
|
||||||
|
.listRowSeparatorTint(Color.white.opacity(0.06))
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Nu live")
|
||||||
|
.font(McFonts.headline)
|
||||||
|
.foregroundStyle(McColors.text)
|
||||||
|
.textCase(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(upcomingSections, id: \.0) { label, dayEvents in
|
||||||
|
Section {
|
||||||
|
ForEach(dayEvents) { ev in
|
||||||
|
Button { path.append(ev.id) } label: {
|
||||||
|
EventRowView(event: ev, now: now)
|
||||||
|
}
|
||||||
|
.listRowBackground(McColors.bgElevated)
|
||||||
|
.listRowSeparatorTint(Color.white.opacity(0.06))
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(label)
|
||||||
|
.font(McFonts.headline)
|
||||||
|
.foregroundStyle(McColors.text)
|
||||||
|
.textCase(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.refreshable { await refresh(isPull: true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var providerChips: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
chip(label: "Alles", active: providerFilter == nil) {
|
||||||
|
providerFilter = nil
|
||||||
|
}
|
||||||
|
ForEach(providers, id: \.self) { p in
|
||||||
|
chip(label: EventHelpers.prettyProvider(p), active: providerFilter?.caseInsensitiveCompare(p) == .orderedSame) {
|
||||||
|
providerFilter = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chip(label: String, active: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(label)
|
||||||
|
.font(McFonts.caption)
|
||||||
|
.foregroundStyle(active ? .white : McColors.textDim)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(active ? McColors.accent : McColors.surface, in: Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh(isPull: Bool = false, silent: Bool = false) async {
|
||||||
|
if isPull { refreshing = true }
|
||||||
|
if !silent && events.isEmpty { loading = true }
|
||||||
|
do {
|
||||||
|
let payload = try await app.api.listScheduleEvents()
|
||||||
|
events = EventHelpers.enrichImages(payload.events)
|
||||||
|
error = nil
|
||||||
|
} catch {
|
||||||
|
if events.isEmpty {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
refreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detail
|
||||||
|
|
||||||
|
struct EventDetailScreen: View {
|
||||||
|
@EnvironmentObject private var app: AppModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let eventId: String
|
||||||
|
|
||||||
|
@State private var event: ScheduleEvent?
|
||||||
|
@State private var siblings: [ScheduleEvent] = []
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var now = Date()
|
||||||
|
@State private var playInfo: IptvPlayInfo?
|
||||||
|
@State private var playError: String?
|
||||||
|
|
||||||
|
private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let event {
|
||||||
|
detailBody(event)
|
||||||
|
} else if error != nil {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(error ?? "Fout").foregroundStyle(McColors.danger)
|
||||||
|
Button("Terug") { dismiss() }.buttonStyle(McPrimaryButtonStyle())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView("Laden…").tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(McColors.bg.ignoresSafeArea())
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
.task { await load() }
|
||||||
|
.onReceive(clock) { date in
|
||||||
|
now = date
|
||||||
|
Task { await refreshEvent() }
|
||||||
|
}
|
||||||
|
.fullScreenCover(item: Binding(
|
||||||
|
get: { playInfo.map { EventPlayItem(info: $0) } },
|
||||||
|
set: { playInfo = $0?.info }
|
||||||
|
)) { item in
|
||||||
|
if let url = URL(string: item.info.streamUrl) {
|
||||||
|
let fallback = item.info.fallbackStreamUrl.flatMap(URL.init(string:))
|
||||||
|
PlayerScreen(request: .live(
|
||||||
|
url: url,
|
||||||
|
title: item.info.name,
|
||||||
|
fallback: fallback,
|
||||||
|
format: item.info.format,
|
||||||
|
drm: item.info.drm,
|
||||||
|
watchSessionId: item.info.watchSessionId,
|
||||||
|
watchKind: "event",
|
||||||
|
watchId: eventId
|
||||||
|
))
|
||||||
|
.environmentObject(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Afspelen mislukt", isPresented: Binding(
|
||||||
|
get: { playError != nil },
|
||||||
|
set: { if !$0 { playError = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK", role: .cancel) { playError = nil }
|
||||||
|
} message: {
|
||||||
|
Text(playError ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detailBody(_ ev: ScheduleEvent) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
hero(ev)
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(ev.name)
|
||||||
|
.font(McFonts.title)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(3)
|
||||||
|
|
||||||
|
let meta = EventHelpers.detailMeta(ev)
|
||||||
|
if !meta.isEmpty {
|
||||||
|
Text(meta)
|
||||||
|
.font(McFonts.body)
|
||||||
|
.foregroundStyle(Color(white: 0.8))
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = EventHelpers.detailTimeWindow(ev, now: now)
|
||||||
|
if !window.isEmpty {
|
||||||
|
Text(window)
|
||||||
|
.font(McFonts.headline)
|
||||||
|
.foregroundStyle(McColors.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let subtitle = ev.subtitle, !subtitle.isEmpty,
|
||||||
|
subtitle.caseInsensitiveCompare(ev.name) != .orderedSame {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(McFonts.body)
|
||||||
|
.foregroundStyle(McColors.textDim)
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let description = ev.description, !description.isEmpty {
|
||||||
|
Text(description)
|
||||||
|
.font(McFonts.body)
|
||||||
|
.foregroundStyle(Color(white: 0.91))
|
||||||
|
.lineSpacing(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let langs = ev.audioLanguages, !langs.isEmpty {
|
||||||
|
Text("Audio")
|
||||||
|
.font(McFonts.caption)
|
||||||
|
.foregroundStyle(Color(white: 0.56))
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(langs.prefix(8), id: \.self) { lang in
|
||||||
|
Text(lang.uppercased())
|
||||||
|
.font(McFonts.caption)
|
||||||
|
.foregroundStyle(Color(white: 0.86))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Color.white.opacity(0.2), in: RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stickyBar(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hero(_ ev: ScheduleEvent) -> some View {
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill)
|
||||||
|
.frame(height: 220)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.clipped()
|
||||||
|
.overlay {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: .black.opacity(0.2), location: 0),
|
||||||
|
.init(color: .clear, location: 0.45),
|
||||||
|
.init(color: McColors.bg.opacity(0.95), location: 1),
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Text("Terug")
|
||||||
|
.font(McFonts.caption)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.black.opacity(0.4), in: RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let logo = ev.logoUrl, !logo.isEmpty {
|
||||||
|
RemoteImage(url: logo, contentMode: .fit, opaqueBackground: false)
|
||||||
|
.frame(width: 96, height: 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
|
||||||
|
EventStatusBadge(event: ev, now: now)
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.frame(height: 220)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stickyBar(_ ev: ScheduleEvent) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if ev.isPlayable {
|
||||||
|
Button {
|
||||||
|
Task { await play(ev) }
|
||||||
|
} label: {
|
||||||
|
Text("Kijk live")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(McPrimaryButtonStyle())
|
||||||
|
|
||||||
|
Button("Terug") { dismiss() }
|
||||||
|
.buttonStyle(McSecondaryButtonStyle())
|
||||||
|
} else {
|
||||||
|
Button {} label: {
|
||||||
|
Text(EventHelpers.ctaLabel(ev, now: now))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(McSecondaryButtonStyle())
|
||||||
|
.disabled(true)
|
||||||
|
.opacity(0.55)
|
||||||
|
|
||||||
|
Button("Terug") { dismiss() }
|
||||||
|
.buttonStyle(McPrimaryButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(McColors.bgElevated.ignoresSafeArea(edges: .bottom))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
do {
|
||||||
|
let list = try await app.api.listScheduleEvents()
|
||||||
|
siblings = EventHelpers.enrichImages(list.events)
|
||||||
|
let raw = try await app.api.getScheduleEvent(eventId: eventId)
|
||||||
|
event = EventHelpers.enrichSingle(raw, siblings: siblings)
|
||||||
|
error = nil
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshEvent() async {
|
||||||
|
guard let raw = try? await app.api.getScheduleEvent(eventId: eventId) else { return }
|
||||||
|
event = EventHelpers.enrichSingle(raw, siblings: siblings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func play(_ ev: ScheduleEvent) async {
|
||||||
|
do {
|
||||||
|
playInfo = try await app.api.scheduleEventPlay(eventId: ev.id)
|
||||||
|
} catch {
|
||||||
|
playError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EventPlayItem: Identifiable {
|
||||||
|
var id: String { info.streamId.isEmpty ? info.name : info.streamId }
|
||||||
|
let info: IptvPlayInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Row
|
||||||
|
|
||||||
|
private struct EventRowView: View {
|
||||||
|
let event: ScheduleEvent
|
||||||
|
let now: Date
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.fill(Color(white: 0.1))
|
||||||
|
.frame(width: 112, height: 63)
|
||||||
|
if let art = EventHelpers.artUrl(event) {
|
||||||
|
RemoteImage(url: art, contentMode: .fill)
|
||||||
|
.frame(width: 112, height: 63)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
} else {
|
||||||
|
Text(EventHelpers.prettyProvider(event.provider))
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(McColors.textDim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 112, height: 63)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
EventStatusBadge(event: event, now: now, compact: true)
|
||||||
|
Text(EventHelpers.shortTime(event, now: now))
|
||||||
|
.font(McFonts.caption)
|
||||||
|
.foregroundStyle(McColors.textDim)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Text(event.name)
|
||||||
|
.font(McFonts.headline)
|
||||||
|
.foregroundStyle(McColors.text)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text(
|
||||||
|
[EventHelpers.prettyProvider(event.provider), EventHelpers.displayChannel(event)]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.uniqued()
|
||||||
|
.joined(separator: " · ")
|
||||||
|
)
|
||||||
|
.font(McFonts.caption)
|
||||||
|
.foregroundStyle(McColors.textDim)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EventStatusBadge: View {
|
||||||
|
let event: ScheduleEvent
|
||||||
|
let now: Date
|
||||||
|
var compact: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let (label, color) = EventHelpers.statusVisual(event, now: now)
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: compact ? 11 : 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, compact ? 8 : 10)
|
||||||
|
.padding(.vertical, compact ? 3 : 5)
|
||||||
|
.background(color, in: RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
enum EventHelpers {
|
||||||
|
static let zone = TimeZone(identifier: "Europe/Amsterdam") ?? .current
|
||||||
|
private static let streamLabel = try! NSRegularExpression(pattern: #"^stream\s*\d+"#, options: .caseInsensitive)
|
||||||
|
|
||||||
|
static func isLive(_ event: ScheduleEvent, now: Date) -> Bool {
|
||||||
|
if event.phaseValue == "live" || event.isLive == true { return true }
|
||||||
|
guard let start = parse(event.startLocal, event.start),
|
||||||
|
let stop = parse(event.stopLocal, event.stop) else { return false }
|
||||||
|
return now >= start && now < stop
|
||||||
|
}
|
||||||
|
|
||||||
|
static func minutesUntilStart(_ event: ScheduleEvent, now: Date) -> Int? {
|
||||||
|
guard let start = parse(event.startLocal, event.start) else { return nil }
|
||||||
|
let m = Int(start.timeIntervalSince(now) / 60)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
static func minutesUntilEnd(_ event: ScheduleEvent, now: Date) -> Int? {
|
||||||
|
guard let stop = parse(event.stopLocal, event.stop) else { return nil }
|
||||||
|
let m = Int(stop.timeIntervalSince(now) / 60)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
static func statusVisual(_ event: ScheduleEvent, now: Date) -> (String, Color) {
|
||||||
|
if isLive(event, now: now) { return ("LIVE", McColors.accent) }
|
||||||
|
if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 {
|
||||||
|
return ("Straks · over \(m) min", Color(red: 0.9, green: 0.49, blue: 0.13))
|
||||||
|
}
|
||||||
|
return ("Gepland", Color(white: 0.23))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ctaLabel(_ event: ScheduleEvent, now: Date) -> String {
|
||||||
|
if isLive(event, now: now) { return "Stream start binnenkort" }
|
||||||
|
if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 {
|
||||||
|
return "Start over \(m) min"
|
||||||
|
}
|
||||||
|
guard let start = parse(event.startLocal, event.start) else { return "Meer info" }
|
||||||
|
return "Start om \(timeFmt.string(from: start))"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shortTime(_ event: ScheduleEvent, now: Date) -> String {
|
||||||
|
if isLive(event, now: now) {
|
||||||
|
if let left = minutesUntilEnd(event, now: now), left > 0 { return "Nog \(left) min" }
|
||||||
|
return "Nu"
|
||||||
|
}
|
||||||
|
if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 {
|
||||||
|
return "Over \(m) min"
|
||||||
|
}
|
||||||
|
guard let start = parse(event.startLocal, event.start) else { return "" }
|
||||||
|
let cal = Calendar.current
|
||||||
|
var c = cal
|
||||||
|
c.timeZone = zone
|
||||||
|
let today = c.startOfDay(for: now)
|
||||||
|
let day = c.startOfDay(for: start)
|
||||||
|
if day == today { return "Vandaag \(timeFmt.string(from: start))" }
|
||||||
|
if day == c.date(byAdding: .day, value: 1, to: today) {
|
||||||
|
return "Morgen \(timeFmt.string(from: start))"
|
||||||
|
}
|
||||||
|
return dayTimeFmt.string(from: start)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func detailTimeWindow(_ event: ScheduleEvent, now: Date) -> String {
|
||||||
|
guard let start = parse(event.startLocal, event.start) else { return "" }
|
||||||
|
let stop = parse(event.stopLocal, event.stop)
|
||||||
|
let range: String = {
|
||||||
|
if let stop {
|
||||||
|
return "\(timeFmt.string(from: start)) – \(timeFmt.string(from: stop))"
|
||||||
|
}
|
||||||
|
return timeFmt.string(from: start)
|
||||||
|
}()
|
||||||
|
if isLive(event, now: now) { return range }
|
||||||
|
var c = Calendar.current
|
||||||
|
c.timeZone = zone
|
||||||
|
let today = c.startOfDay(for: now)
|
||||||
|
let day = c.startOfDay(for: start)
|
||||||
|
if day == today { return "Vandaag · \(range)" }
|
||||||
|
if day == c.date(byAdding: .day, value: 1, to: today) {
|
||||||
|
return "Morgen · \(range)"
|
||||||
|
}
|
||||||
|
if let stop {
|
||||||
|
return "\(dayTimeFmt.string(from: start)) – \(timeFmt.string(from: stop))"
|
||||||
|
}
|
||||||
|
return dayTimeFmt.string(from: start)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func displayChannel(_ event: ScheduleEvent) -> String? {
|
||||||
|
let raw = event.channel?.nilIfBlank ?? event.network?.nilIfBlank
|
||||||
|
guard let raw, !isStreamLabel(raw) else { return nil }
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
static func detailMeta(_ event: ScheduleEvent) -> String {
|
||||||
|
[
|
||||||
|
prettyProvider(event.provider),
|
||||||
|
event.category?.nilIfBlank,
|
||||||
|
event.eventType?.nilIfBlank,
|
||||||
|
displayChannel(event),
|
||||||
|
]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.uniqued()
|
||||||
|
.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func artUrl(_ event: ScheduleEvent) -> String? {
|
||||||
|
if let u = event.imageLandscape?.nilIfBlank { return u }
|
||||||
|
let imgs = event.images ?? []
|
||||||
|
if let u = imgs.first(where: { looksLikeArtwork($0) && !$0.localizedCaseInsensitiveContains("hero-16x9") }) {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
if let u = imgs.first(where: { looksLikeArtwork($0) }) { return u }
|
||||||
|
if let u = event.imagePortrait?.nilIfBlank { return u }
|
||||||
|
return imgs.first { !$0.isEmpty && !$0.localizedCaseInsensitiveContains("assets/channels") }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func backdropUrl(_ event: ScheduleEvent) -> String? {
|
||||||
|
if let u = event.imageBackdrop?.nilIfBlank { return u }
|
||||||
|
if let u = (event.images ?? []).first(where: { $0.localizedCaseInsensitiveContains("hero-16x9") }) {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return artUrl(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func prettyProvider(_ raw: String) -> String {
|
||||||
|
switch raw.lowercased() {
|
||||||
|
case "viaplay": return "Viaplay"
|
||||||
|
case "ziggosport": return "Ziggo Sport"
|
||||||
|
case "espn": return "ESPN"
|
||||||
|
case "appletv": return "Apple TV"
|
||||||
|
case "prime_hbo": return "Prime / HBO"
|
||||||
|
case "prime": return "Prime Video"
|
||||||
|
case "disneyplus": return "Disney+"
|
||||||
|
case "eyecons": return "Eyecons"
|
||||||
|
case "odido": return "Odido"
|
||||||
|
default:
|
||||||
|
return raw.replacingOccurrences(of: "_", with: " ").capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func groupByDay(_ events: [ScheduleEvent]) -> [(String, [ScheduleEvent])] {
|
||||||
|
var c = Calendar.current
|
||||||
|
c.timeZone = zone
|
||||||
|
let today = c.startOfDay(for: Date())
|
||||||
|
var groups: [(String, [ScheduleEvent])] = []
|
||||||
|
var index: [String: Int] = [:]
|
||||||
|
|
||||||
|
for e in events {
|
||||||
|
guard let start = parse(e.startLocal, e.start) else { continue }
|
||||||
|
let day = c.startOfDay(for: start)
|
||||||
|
let label: String
|
||||||
|
if day == today {
|
||||||
|
label = "Vandaag"
|
||||||
|
} else if day == c.date(byAdding: .day, value: 1, to: today) {
|
||||||
|
label = "Morgen"
|
||||||
|
} else if day == c.date(byAdding: .day, value: 2, to: today) {
|
||||||
|
label = "Overmorgen"
|
||||||
|
} else {
|
||||||
|
label = fullDayFmt.string(from: start)
|
||||||
|
}
|
||||||
|
if let i = index[label] {
|
||||||
|
groups[i].1.append(e)
|
||||||
|
} else {
|
||||||
|
index[label] = groups.count
|
||||||
|
groups.append((label, [e]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
static func enrichImages(_ events: [ScheduleEvent]) -> [ScheduleEvent] {
|
||||||
|
var donors: [String: ScheduleEvent] = [:]
|
||||||
|
for e in events {
|
||||||
|
if e.imageLandscape == nil && (e.images ?? []).isEmpty && e.logoUrl == nil { continue }
|
||||||
|
for key in [e.channel, e.network].compactMap({ normalizeChannel($0) }) {
|
||||||
|
if let existing = donors[key], donorScore(existing) >= donorScore(e) { continue }
|
||||||
|
donors[key] = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events.map { enrichSingle($0, siblings: events, donors: donors) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func enrichSingle(
|
||||||
|
_ e: ScheduleEvent,
|
||||||
|
siblings: [ScheduleEvent],
|
||||||
|
donors: [String: ScheduleEvent]? = nil
|
||||||
|
) -> ScheduleEvent {
|
||||||
|
let cleanedChannel = e.channel.flatMap { isStreamLabel($0) ? nil : $0 }
|
||||||
|
let cleanedNetwork = e.network.flatMap { isStreamLabel($0) ? nil : $0 }
|
||||||
|
if e.imageLandscape != nil || !(e.images ?? []).isEmpty {
|
||||||
|
return e.withImages(channel: cleanedChannel, network: cleanedNetwork)
|
||||||
|
}
|
||||||
|
let map: [String: ScheduleEvent] = donors ?? {
|
||||||
|
var built: [String: ScheduleEvent] = [:]
|
||||||
|
for sib in siblings {
|
||||||
|
if sib.imageLandscape == nil && (sib.images ?? []).isEmpty { continue }
|
||||||
|
for key in [sib.channel, sib.network].compactMap({ normalizeChannel($0) }) {
|
||||||
|
if let existing = built[key], donorScore(existing) >= donorScore(sib) { continue }
|
||||||
|
built[key] = sib
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return built
|
||||||
|
}()
|
||||||
|
var donor: ScheduleEvent?
|
||||||
|
for key in [e.channel, e.network].compactMap({ normalizeChannel($0) }) {
|
||||||
|
if let hit = map[key], hit.id != e.id {
|
||||||
|
donor = hit
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let donor else {
|
||||||
|
return e.withImages(channel: cleanedChannel, network: cleanedNetwork)
|
||||||
|
}
|
||||||
|
return e.withImages(
|
||||||
|
landscape: e.imageLandscape ?? donor.imageLandscape,
|
||||||
|
backdrop: e.imageBackdrop ?? donor.imageBackdrop,
|
||||||
|
portrait: e.imagePortrait ?? donor.imagePortrait,
|
||||||
|
logo: e.logoUrl ?? donor.logoUrl,
|
||||||
|
imgs: (e.images?.isEmpty == false ? e.images : donor.images),
|
||||||
|
channel: cleanedChannel,
|
||||||
|
network: cleanedNetwork
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func donorScore(_ e: ScheduleEvent) -> Int {
|
||||||
|
var s = 0
|
||||||
|
if e.imageLandscape != nil { s += 20 }
|
||||||
|
if e.imageBackdrop != nil { s += 10 }
|
||||||
|
if (e.images ?? []).contains(where: {
|
||||||
|
$0.localizedCaseInsensitiveContains("thumb") || $0.localizedCaseInsensitiveContains("akamaized.net/live")
|
||||||
|
}) { s += 30 }
|
||||||
|
if e.logoUrl != nil { s += 5 }
|
||||||
|
s += min((e.images ?? []).count, 5)
|
||||||
|
if e.provider.caseInsensitiveCompare("ziggosport") == .orderedSame { s += 8 }
|
||||||
|
if e.isPlayable { s += 3 }
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeChannel(_ value: String?) -> String? {
|
||||||
|
let s = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) ?? ""
|
||||||
|
return s.isEmpty ? nil : s
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isStreamLabel(_ value: String) -> Bool {
|
||||||
|
let s = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !s.isEmpty else { return false }
|
||||||
|
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
||||||
|
return streamLabel.firstMatch(in: s, options: [], range: range) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func looksLikeArtwork(_ url: String) -> Bool {
|
||||||
|
let u = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if u.isEmpty { return false }
|
||||||
|
let lower = u.lowercased()
|
||||||
|
if lower.contains("assets/channels") { return false }
|
||||||
|
if lower.contains("channels-logo") { return false }
|
||||||
|
if lower.contains("/logos/") && lower.hasSuffix(".png") { return false }
|
||||||
|
if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")),
|
||||||
|
!lower.contains("boxart"), !lower.contains("hero-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lower.hasSuffix(".svg") { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parse(_ local: String?, _ utc: String) -> Date? {
|
||||||
|
let raw = local?.nilIfBlank ?? utc.nilIfBlank
|
||||||
|
guard let raw else { return nil }
|
||||||
|
let isoFrac = ISO8601DateFormatter()
|
||||||
|
isoFrac.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let d = isoFrac.date(from: raw) { return d }
|
||||||
|
let iso = ISO8601DateFormatter()
|
||||||
|
iso.formatOptions = [.withInternetDateTime]
|
||||||
|
if let d = iso.date(from: raw) { return d }
|
||||||
|
let withZ = raw.hasSuffix("Z") || raw.contains("+") ? raw : "\(raw)Z"
|
||||||
|
return iso.date(from: withZ) ?? isoFrac.date(from: withZ)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let timeFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "nl_NL")
|
||||||
|
f.timeZone = zone
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let dayTimeFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "nl_NL")
|
||||||
|
f.timeZone = zone
|
||||||
|
f.dateFormat = "EEE d MMM · HH:mm"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let fullDayFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "nl_NL")
|
||||||
|
f.timeZone = zone
|
||||||
|
f.dateFormat = "EEEE d MMMM"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var nilIfBlank: String? {
|
||||||
|
let t = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return t.isEmpty ? nil : t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array where Element: Hashable {
|
||||||
|
func uniqued() -> [Element] {
|
||||||
|
var seen = Set<Element>()
|
||||||
|
return filter { seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -125,8 +125,18 @@ struct LiveTvScreen: View {
|
||||||
)) { item in
|
)) { item in
|
||||||
if let url = URL(string: item.info.streamUrl) {
|
if let url = URL(string: item.info.streamUrl) {
|
||||||
let fallback = item.info.fallbackStreamUrl.flatMap(URL.init(string:))
|
let fallback = item.info.fallbackStreamUrl.flatMap(URL.init(string:))
|
||||||
PlayerScreen(request: .live(url: url, title: item.info.name, fallback: fallback))
|
let kind = item.info.streamId.hasPrefix("custom:") ? "live-list" : nil
|
||||||
.environmentObject(app)
|
PlayerScreen(request: .live(
|
||||||
|
url: url,
|
||||||
|
title: item.info.name,
|
||||||
|
fallback: fallback,
|
||||||
|
format: item.info.format,
|
||||||
|
drm: item.info.drm,
|
||||||
|
watchSessionId: item.info.watchSessionId,
|
||||||
|
watchKind: kind,
|
||||||
|
watchId: item.info.streamId
|
||||||
|
))
|
||||||
|
.environmentObject(app)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -188,8 +198,10 @@ struct LiveTvScreen: View {
|
||||||
private func loadChannels() async {
|
private func loadChannels() async {
|
||||||
do {
|
do {
|
||||||
channels = try await app.api.iptvChannels(categoryId: selectedCategory, includeEpg: true)
|
channels = try await app.api.iptvChannels(categoryId: selectedCategory, includeEpg: true)
|
||||||
|
error = nil
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
channels = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,6 +215,6 @@ struct LiveTvScreen: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct LivePlayItem: Identifiable {
|
private struct LivePlayItem: Identifiable {
|
||||||
var id: Int { info.streamId }
|
var id: String { info.streamId }
|
||||||
let info: IptvPlayInfo
|
let info: IptvPlayInfo
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ struct MainShell: View {
|
||||||
NavigationStack { BrowseScreen(kind: "SERIES", title: "Series") }
|
NavigationStack { BrowseScreen(kind: "SERIES", title: "Series") }
|
||||||
case .tv:
|
case .tv:
|
||||||
NavigationStack { LiveTvScreen() }
|
NavigationStack { LiveTvScreen() }
|
||||||
|
case .events:
|
||||||
|
EventsScreen()
|
||||||
case .search:
|
case .search:
|
||||||
NavigationStack { SearchScreen() }
|
NavigationStack { SearchScreen() }
|
||||||
case .account:
|
case .account:
|
||||||
|
|
@ -74,6 +76,7 @@ struct MainShell: View {
|
||||||
private var visibleTabs: [AppModel.MainTab] {
|
private var visibleTabs: [AppModel.MainTab] {
|
||||||
AppModel.MainTab.allCases.filter { tab in
|
AppModel.MainTab.allCases.filter { tab in
|
||||||
if tab == .tv { return app.iptvEnabled }
|
if tab == .tv { return app.iptvEnabled }
|
||||||
|
if tab == .events { return app.eventsEnabled }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,9 @@ struct SettingsScreen: View {
|
||||||
|
|
||||||
Section("Info") {
|
Section("Info") {
|
||||||
LabeledContent("App", value: "VMC iOS")
|
LabeledContent("App", value: "VMC iOS")
|
||||||
LabeledContent("Versie", value: "0.4.7")
|
LabeledContent("Versie", value: "0.5.0")
|
||||||
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
|
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
|
||||||
|
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
|
||||||
}
|
}
|
||||||
.listRowBackground(McColors.bgElevated)
|
.listRowBackground(McColors.bgElevated)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -322,12 +322,20 @@ actor ApiClient {
|
||||||
return wrap.channels
|
return wrap.channels
|
||||||
}
|
}
|
||||||
|
|
||||||
func iptvPlay(streamId: Int) async throws -> IptvPlayInfo {
|
func iptvPlay(streamId: String) async throws -> IptvPlayInfo {
|
||||||
try await get("/api/v1/client/iptv/channels/\(streamId)/play")
|
let enc = Self.encodePathSegment(streamId)
|
||||||
|
return try await get("/api/v1/client/iptv/channels/\(enc)/play")
|
||||||
}
|
}
|
||||||
|
|
||||||
func iptvEpg(streamId: Int) async throws -> IptvEpg {
|
func iptvEpg(streamId: String) async throws -> IptvEpg {
|
||||||
try await get("/api/v1/client/iptv/channels/\(streamId)/epg")
|
let enc = Self.encodePathSegment(streamId)
|
||||||
|
return try await get("/api/v1/client/iptv/channels/\(enc)/epg")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encodePathSegment(_ raw: String) -> String {
|
||||||
|
var allowed = CharacterSet.alphanumerics
|
||||||
|
allowed.insert(charactersIn: "-._~")
|
||||||
|
return raw.addingPercentEncoding(withAllowedCharacters: allowed) ?? raw
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - HTTP
|
// MARK: - HTTP
|
||||||
|
|
|
||||||
|
|
@ -252,24 +252,69 @@ struct IptvCategory: Decodable, Identifiable, Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct IptvChannel: Decodable, Identifiable, Hashable {
|
struct IptvChannel: Decodable, Identifiable, Hashable {
|
||||||
var id: Int { streamId }
|
var id: String { streamId }
|
||||||
let streamId: Int
|
let streamId: String
|
||||||
let name: String
|
let name: String
|
||||||
let logoUrl: String?
|
let logoUrl: String?
|
||||||
let number: Int?
|
let number: Int?
|
||||||
let categoryId: String?
|
let categoryId: String?
|
||||||
let nowTitle: String?
|
let nowTitle: String?
|
||||||
let nextTitle: String?
|
let nextTitle: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case streamId, name, logoUrl, number, categoryId, nowTitle, nextTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
streamId = try decodeFlexibleString(c, forKey: .streamId)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
logoUrl = try c.decodeIfPresent(String.self, forKey: .logoUrl)
|
||||||
|
number = try c.decodeIfPresent(Int.self, forKey: .number)
|
||||||
|
categoryId = try c.decodeIfPresent(String.self, forKey: .categoryId)
|
||||||
|
nowTitle = try c.decodeIfPresent(String.self, forKey: .nowTitle)
|
||||||
|
nextTitle = try c.decodeIfPresent(String.self, forKey: .nextTitle)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct IptvPlayInfo: Decodable {
|
struct IptvPlayInfo: Decodable {
|
||||||
let streamId: Int
|
let streamId: String
|
||||||
let name: String
|
let name: String
|
||||||
let logoUrl: String?
|
let logoUrl: String?
|
||||||
let streamUrl: String
|
let streamUrl: String
|
||||||
let format: String?
|
let format: String?
|
||||||
let fallbackStreamUrl: String?
|
let fallbackStreamUrl: String?
|
||||||
let fallbackFormat: String?
|
let fallbackFormat: String?
|
||||||
|
let drm: DrmInfo?
|
||||||
|
let watchSessionId: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case streamId, name, logoUrl, streamUrl, format
|
||||||
|
case fallbackStreamUrl, fallbackFormat, drm, watchSessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
streamId = try decodeFlexibleString(c, forKey: .streamId)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
logoUrl = try c.decodeIfPresent(String.self, forKey: .logoUrl)
|
||||||
|
streamUrl = try c.decode(String.self, forKey: .streamUrl)
|
||||||
|
format = try c.decodeIfPresent(String.self, forKey: .format)
|
||||||
|
fallbackStreamUrl = try c.decodeIfPresent(String.self, forKey: .fallbackStreamUrl)
|
||||||
|
fallbackFormat = try c.decodeIfPresent(String.self, forKey: .fallbackFormat)
|
||||||
|
drm = try c.decodeIfPresent(DrmInfo.self, forKey: .drm)
|
||||||
|
watchSessionId = try c.decodeIfPresent(String.self, forKey: .watchSessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClearKeyEntry: Decodable, Hashable {
|
||||||
|
let kid: String
|
||||||
|
let key: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DrmInfo: Decodable, Hashable {
|
||||||
|
let type: String
|
||||||
|
let keys: [ClearKeyEntry]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EpgProgram: Decodable, Hashable {
|
struct EpgProgram: Decodable, Hashable {
|
||||||
|
|
@ -300,11 +345,40 @@ struct EpgProgram: Decodable, Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct IptvEpg: Decodable, Hashable {
|
struct IptvEpg: Decodable, Hashable {
|
||||||
let streamId: Int
|
let streamId: String
|
||||||
let channelName: String
|
let channelName: String
|
||||||
let logoUrl: String?
|
let logoUrl: String?
|
||||||
let now: EpgProgram?
|
let now: EpgProgram?
|
||||||
let next: EpgProgram?
|
let next: EpgProgram?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case streamId, channelName, logoUrl, now, next
|
||||||
|
}
|
||||||
|
|
||||||
|
init(streamId: String, channelName: String, logoUrl: String?, now: EpgProgram?, next: EpgProgram?) {
|
||||||
|
self.streamId = streamId
|
||||||
|
self.channelName = channelName
|
||||||
|
self.logoUrl = logoUrl
|
||||||
|
self.now = now
|
||||||
|
self.next = next
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
streamId = (try? decodeFlexibleString(c, forKey: .streamId)) ?? ""
|
||||||
|
channelName = try c.decode(String.self, forKey: .channelName)
|
||||||
|
logoUrl = try c.decodeIfPresent(String.self, forKey: .logoUrl)
|
||||||
|
now = try c.decodeIfPresent(EpgProgram.self, forKey: .now)
|
||||||
|
next = try c.decodeIfPresent(EpgProgram.self, forKey: .next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API streamId kan String (`"123"` / `"custom:uuid"`) of legacy Int zijn.
|
||||||
|
func decodeFlexibleString<K: CodingKey>(_ c: KeyedDecodingContainer<K>, forKey key: K) throws -> String {
|
||||||
|
if let s = try? c.decode(String.self, forKey: key) { return s }
|
||||||
|
if let i = try? c.decode(Int.self, forKey: key) { return String(i) }
|
||||||
|
if let d = try? c.decode(Double.self, forKey: key) { return String(Int(d)) }
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: key, in: c, debugDescription: "Verwacht string of int id")
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ISO8601DateFormatter {
|
extension ISO8601DateFormatter {
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ final class SessionStore: ObservableObject {
|
||||||
@Published var profileAvatarId: String?
|
@Published var profileAvatarId: String?
|
||||||
@Published var profileShowAvatar: Bool = true
|
@Published var profileShowAvatar: Bool = true
|
||||||
@Published var profileAvatarUrl: String?
|
@Published var profileAvatarUrl: String?
|
||||||
@Published var iptvFavoriteIds: Set<Int> = []
|
@Published var iptvFavoriteIds: Set<String> = []
|
||||||
@Published var lastIptvStreamId: Int?
|
@Published var lastIptvStreamId: String?
|
||||||
|
|
||||||
private let defaults = UserDefaults.standard
|
private let defaults = UserDefaults.standard
|
||||||
private enum Key {
|
private enum Key {
|
||||||
|
|
@ -37,10 +37,20 @@ final class SessionStore: ObservableObject {
|
||||||
let showRaw = defaults.string(forKey: Key.profileShowAvatar)
|
let showRaw = defaults.string(forKey: Key.profileShowAvatar)
|
||||||
profileShowAvatar = showRaw != "0"
|
profileShowAvatar = showRaw != "0"
|
||||||
profileAvatarUrl = defaults.string(forKey: Key.profileAvatarUrl)
|
profileAvatarUrl = defaults.string(forKey: Key.profileAvatarUrl)
|
||||||
let favs = defaults.array(forKey: Key.iptvFavorites) as? [Int] ?? []
|
if let favStrings = defaults.array(forKey: Key.iptvFavorites) as? [String] {
|
||||||
iptvFavoriteIds = Set(favs)
|
iptvFavoriteIds = Set(favStrings)
|
||||||
let last = defaults.integer(forKey: Key.iptvLastStream)
|
} else if let favInts = defaults.array(forKey: Key.iptvFavorites) as? [Int] {
|
||||||
lastIptvStreamId = last > 0 ? last : nil
|
iptvFavoriteIds = Set(favInts.map(String.init))
|
||||||
|
defaults.set(Array(iptvFavoriteIds), forKey: Key.iptvFavorites)
|
||||||
|
} else {
|
||||||
|
iptvFavoriteIds = []
|
||||||
|
}
|
||||||
|
if let lastStr = defaults.string(forKey: Key.iptvLastStream), !lastStr.isEmpty {
|
||||||
|
lastIptvStreamId = lastStr
|
||||||
|
} else {
|
||||||
|
let last = defaults.integer(forKey: Key.iptvLastStream)
|
||||||
|
lastIptvStreamId = last > 0 ? String(last) : nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAuthed: Bool { !(refreshToken ?? "").isEmpty }
|
var isAuthed: Bool { !(refreshToken ?? "").isEmpty }
|
||||||
|
|
@ -105,12 +115,12 @@ final class SessionStore: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isIptvFavorite(_ streamId: Int) -> Bool {
|
func isIptvFavorite(_ streamId: String) -> Bool {
|
||||||
iptvFavoriteIds.contains(streamId)
|
iptvFavoriteIds.contains(streamId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func toggleIptvFavorite(_ streamId: Int) -> Bool {
|
func toggleIptvFavorite(_ streamId: String) -> Bool {
|
||||||
if iptvFavoriteIds.contains(streamId) {
|
if iptvFavoriteIds.contains(streamId) {
|
||||||
iptvFavoriteIds.remove(streamId)
|
iptvFavoriteIds.remove(streamId)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -120,7 +130,7 @@ final class SessionStore: ObservableObject {
|
||||||
return iptvFavoriteIds.contains(streamId)
|
return iptvFavoriteIds.contains(streamId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setLastIptvStream(_ streamId: Int) {
|
func setLastIptvStream(_ streamId: String) {
|
||||||
lastIptvStreamId = streamId
|
lastIptvStreamId = streamId
|
||||||
defaults.set(streamId, forKey: Key.iptvLastStream)
|
defaults.set(streamId, forKey: Key.iptvLastStream)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ struct PlayRequest: Identifiable {
|
||||||
let subtitleFileURL: URL?
|
let subtitleFileURL: URL?
|
||||||
let logoUrl: String?
|
let logoUrl: String?
|
||||||
/// Live-only context for zap / zenderlijst / EPG.
|
/// Live-only context for zap / zenderlijst / EPG.
|
||||||
let liveStreamId: Int?
|
let liveStreamId: String?
|
||||||
let liveChannels: [IptvChannel]
|
let liveChannels: [IptvChannel]
|
||||||
let liveCategoryId: String?
|
let liveCategoryId: String?
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ struct PlayRequest: Identifiable {
|
||||||
title: String,
|
title: String,
|
||||||
fallback: URL?,
|
fallback: URL?,
|
||||||
logoUrl: String? = nil,
|
logoUrl: String? = nil,
|
||||||
streamId: Int? = nil,
|
streamId: String? = nil,
|
||||||
channels: [IptvChannel] = [],
|
channels: [IptvChannel] = [],
|
||||||
categoryId: String? = nil
|
categoryId: String? = nil
|
||||||
) -> PlayRequest {
|
) -> PlayRequest {
|
||||||
|
|
@ -89,7 +89,7 @@ struct TVPlayerView: View {
|
||||||
@State private var progressTask: Task<Void, Never>?
|
@State private var progressTask: Task<Void, Never>?
|
||||||
@State private var hideTask: Task<Void, Never>?
|
@State private var hideTask: Task<Void, Never>?
|
||||||
@FocusState private var focusedControl: PlayerControlFocus?
|
@FocusState private var focusedControl: PlayerControlFocus?
|
||||||
@State private var liveStreamId: Int?
|
@State private var liveStreamId: String?
|
||||||
@State private var liveTitle: String = ""
|
@State private var liveTitle: String = ""
|
||||||
@State private var liveLogoUrl: String?
|
@State private var liveLogoUrl: String?
|
||||||
@State private var liveEpg: IptvEpg?
|
@State private var liveEpg: IptvEpg?
|
||||||
|
|
@ -690,7 +690,7 @@ struct TVPlayerView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshLiveEpg(streamId: Int) async {
|
private func refreshLiveEpg(streamId: String) async {
|
||||||
liveEpg = try? await app.api.iptvEpg(streamId: streamId)
|
liveEpg = try? await app.api.iptvEpg(streamId: streamId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ struct LiveTvView: View {
|
||||||
@State private var channels: [IptvChannel] = []
|
@State private var channels: [IptvChannel] = []
|
||||||
@State private var allChannelsCache: [IptvChannel] = []
|
@State private var allChannelsCache: [IptvChannel] = []
|
||||||
@State private var selectedCategory: String = "__all__"
|
@State private var selectedCategory: String = "__all__"
|
||||||
@FocusState private var focusedStreamId: Int?
|
@FocusState private var focusedStreamId: String?
|
||||||
@State private var epg: IptvEpg?
|
@State private var epg: IptvEpg?
|
||||||
@State private var epgLoading = false
|
@State private var epgLoading = false
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
|
|
@ -338,7 +338,7 @@ struct LiveTvView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadEpg(streamId: Int) async {
|
private func loadEpg(streamId: String) async {
|
||||||
epgLoading = true
|
epgLoading = true
|
||||||
defer { epgLoading = false }
|
defer { epgLoading = false }
|
||||||
do {
|
do {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue