From 3e2549c33a7673775dfdbe85ba224d0591cb20c7 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sat, 19 Sep 2026 03:59:51 +0200 Subject: [PATCH] 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. --- apps/ios/README.md | 8 + apps/ios/VMC.xcodeproj/project.pbxproj | 12 +- apps/ios/VMC/Data/ApiClient.swift | 48 +- apps/ios/VMC/Data/AppModel.swift | 12 +- apps/ios/VMC/Data/Models.swift | 276 +++++- apps/ios/VMC/Playback/PlayerScreen.swift | 64 +- .../VMC/Playback/VLCPlayerController.swift | 67 +- apps/ios/VMC/UI/EventsScreen.swift | 803 ++++++++++++++++++ apps/ios/VMC/UI/LiveTvScreen.swift | 18 +- apps/ios/VMC/UI/MainShell.swift | 3 + apps/ios/VMC/UI/SettingsScreen.swift | 3 +- apps/ios/VMCTV/Data/ApiClient.swift | 16 +- apps/ios/VMCTV/Data/Models.swift | 82 +- apps/ios/VMCTV/Data/SessionStore.swift | 28 +- apps/ios/VMCTV/Playback/TVPlayerView.swift | 8 +- apps/ios/VMCTV/UI/LiveTvView.swift | 4 +- 16 files changed, 1402 insertions(+), 50 deletions(-) create mode 100644 apps/ios/VMC/UI/EventsScreen.swift diff --git a/apps/ios/README.md b/apps/ios/README.md index 8678ce8..64663f4 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -41,3 +41,11 @@ open VMCTV.xcodeproj ## Let op `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. diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index 368760f..42ed35d 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ CA1BAD3B015E434C85D51CD2 /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F950D46F720C4C359184B2CF /* SearchScreen.swift */; }; 668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A435950EF040DE83F30779 /* DetailScreens.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 */; }; B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.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 = ""; }; F0A435950EF040DE83F30779 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = ""; }; 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = ""; }; + F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsScreen.swift; sourceTree = ""; }; 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = ""; }; D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = ""; }; @@ -168,6 +170,7 @@ F0A435950EF040DE83F30779 /* DetailScreens.swift */, D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */, 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */, + F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */, 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */, C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */, ); @@ -267,6 +270,7 @@ 668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */, B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */, A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */, + E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */, A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */, B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */, ); @@ -317,7 +321,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -332,7 +336,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.4.7; + MARKETING_VERSION = 0.5.0; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -350,7 +354,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -365,7 +369,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.4.7; + MARKETING_VERSION = 0.5.0; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apps/ios/VMC/Data/ApiClient.swift b/apps/ios/VMC/Data/ApiClient.swift index 75a3114..2c83d59 100644 --- a/apps/ios/VMC/Data/ApiClient.swift +++ b/apps/ios/VMC/Data/ApiClient.swift @@ -303,8 +303,52 @@ actor ApiClient { return wrap.channels } - func iptvPlay(streamId: Int) async throws -> IptvPlayInfo { - try await get("/api/v1/client/iptv/channels/\(streamId)/play") + func iptvPlay(streamId: String) async throws -> IptvPlayInfo { + 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 diff --git a/apps/ios/VMC/Data/AppModel.swift b/apps/ios/VMC/Data/AppModel.swift index ab0a350..2512f31 100644 --- a/apps/ios/VMC/Data/AppModel.swift +++ b/apps/ios/VMC/Data/AppModel.swift @@ -15,7 +15,7 @@ final class AppModel: ObservableObject { } 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 title: String { @@ -24,6 +24,7 @@ final class AppModel: ObservableObject { case .movies: return "Films" case .series: return "Series" case .tv: return "TV" + case .events: return "Events" case .search: return "Zoeken" case .account: return "Account" } @@ -35,6 +36,7 @@ final class AppModel: ObservableObject { case .movies: return "film" case .series: return "rectangle.stack.fill" case .tv: return "tv.fill" + case .events: return "sportscourt.fill" case .search: return "magnifyingglass" case .account: return "person.crop.circle.fill" } @@ -48,6 +50,7 @@ final class AppModel: ObservableObject { @Published var phase: Phase = .boot @Published var tab: MainTab = .home @Published var iptvEnabled = false + @Published var eventsEnabled = false @Published var canManageProfiles = false @Published var bootError: String? /// Code van QR/deep link om een ander apparaat goed te keuren (hoofdprofiel). @@ -81,6 +84,7 @@ final class AppModel: ObservableObject { do { let me = try await api.clientMe() iptvEnabled = me.iptvEnabled + eventsEnabled = me.eventsEnabled if let profile = me.profile { store.setProfile( id: profile.id, @@ -118,6 +122,7 @@ final class AppModel: ObservableObject { store.setProfile(id: nil, name: nil, avatarId: nil) if let me = try? await api.clientMe() { iptvEnabled = me.iptvEnabled + eventsEnabled = me.eventsEnabled } phase = .profiles } @@ -132,6 +137,10 @@ final class AppModel: ObservableObject { ) favorites.bind(profileId: profile.id) Task { + if let me = try? await api.clientMe() { + iptvEnabled = me.iptvEnabled + eventsEnabled = me.eventsEnabled + } await favorites.refresh(using: api) if let payload = try? await api.listProfiles() { canManageProfiles = payload.canManage == true @@ -155,6 +164,7 @@ final class AppModel: ObservableObject { await api.configure(baseUrl: store.apiBase, token: nil) favorites.bind(profileId: nil) iptvEnabled = false + eventsEnabled = false pendingApproveCode = nil phase = .server } diff --git a/apps/ios/VMC/Data/Models.swift b/apps/ios/VMC/Data/Models.swift index 72b6bdd..bf4c44a 100644 --- a/apps/ios/VMC/Data/Models.swift +++ b/apps/ios/VMC/Data/Models.swift @@ -88,11 +88,14 @@ struct ProfilesPayload: Decodable { struct ClientMe: Decodable { struct Iptv: Decodable { let enabled: Bool? } + struct Events: Decodable { let enabled: Bool? } let iptv: Iptv? + let events: Events? let profile: ViewerProfile? let deviceId: String? var iptvEnabled: Bool { iptv?.enabled == true } + var eventsEnabled: Bool { events?.enabled == true } } struct LinkedDevice: Decodable, Identifiable, Hashable { @@ -239,23 +242,290 @@ struct IptvCategory: Decodable, Identifiable, Hashable { } struct IptvChannel: Decodable, Identifiable, Hashable { - var id: Int { streamId } - let streamId: Int + var id: String { streamId } + let streamId: String let name: String let logoUrl: String? let number: Int? let categoryId: 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 { - let streamId: Int + let streamId: String let name: String let logoUrl: String? let streamUrl: String let format: String? let fallbackStreamUrl: 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(_ c: KeyedDecodingContainer, 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 { diff --git a/apps/ios/VMC/Playback/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift index f98c815..d97b8fc 100644 --- a/apps/ios/VMC/Playback/PlayerScreen.swift +++ b/apps/ios/VMC/Playback/PlayerScreen.swift @@ -13,6 +13,11 @@ struct PlayRequest: Identifiable { let isLive: Bool let fallbackUrl: URL? let subtitleFileURL: URL? + let format: String? + let drm: DrmInfo? + let watchSessionId: String? + let watchKind: String? + let watchId: String? static func vod( url: URL, @@ -28,17 +33,34 @@ struct PlayRequest: Identifiable { PlayRequest( url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId, 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( url: url, title: title, mediaType: "live", mediaFileId: 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 { @@ -53,6 +75,7 @@ struct PlayerScreen: View { @State private var error: String? @State private var progressTask: Task? @State private var hideTask: Task? + @State private var heartbeatTask: Task? @State private var showTracks = false @State private var trackTab = 0 @State private var subtitleHits: [SubtitleHit] = [] @@ -97,11 +120,13 @@ struct PlayerScreen: View { .task { await start() scheduleHide() + startHeartbeatIfNeeded() } .onDisappear { app.leavePlayback() hideTask?.cancel() progressTask?.cancel() + heartbeatTask?.cancel() Task { await saveProgress() } player.stop() } @@ -361,16 +386,45 @@ struct PlayerScreen: View { loading = true error = nil 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 { player.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL) progressTask = Task { await progressLoop() } } - try? await Task.sleep(nanoseconds: 900_000_000) + try? await Task.sleep(nanoseconds: 400_000_000) loading = false 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 { while !Task.isCancelled { try? await Task.sleep(nanoseconds: 15_000_000_000) diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift index 851feb4..68b4563 100644 --- a/apps/ios/VMC/Playback/VLCPlayerController.swift +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -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 errorMessage = nil stateText = "Live laden…" let media = VLCMedia(url: url) media.delegate = self - media.addOption(":network-caching=1200") - media.addOption(":http-reconnect") - media.addOption(":live-caching=1000") - media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") + Self.applyLiveOptions(to: media, url: url, format: format, clearKeys: clearKeys) mediaPlayer.media = media media.parse() mediaPlayer.play() @@ -85,11 +87,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg if let fallback { DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in 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) fb.delegate = self - fb.addOption(":network-caching=1200") - fb.addOption(":http-reconnect") - fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") + Self.applyLiveOptions(to: fb, url: fallback, format: nil, clearKeys: []) self.mediaPlayer.media = fb fb.parse() self.mediaPlayer.play() @@ -99,6 +100,56 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg 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() { if mediaPlayer.isPlaying { mediaPlayer.pause() } else { mediaPlayer.play() } diff --git a/apps/ios/VMC/UI/EventsScreen.swift b/apps/ios/VMC/UI/EventsScreen.swift new file mode 100644 index 0000000..2a0f995 --- /dev/null +++ b/apps/ios/VMC/UI/EventsScreen.swift @@ -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.. 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() + return filter { seen.insert($0).inserted } + } +} diff --git a/apps/ios/VMC/UI/LiveTvScreen.swift b/apps/ios/VMC/UI/LiveTvScreen.swift index 553b2c7..dd4a9fe 100644 --- a/apps/ios/VMC/UI/LiveTvScreen.swift +++ b/apps/ios/VMC/UI/LiveTvScreen.swift @@ -125,8 +125,18 @@ struct LiveTvScreen: View { )) { 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)) - .environmentObject(app) + let kind = item.info.streamId.hasPrefix("custom:") ? "live-list" : nil + 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 { do { channels = try await app.api.iptvChannels(categoryId: selectedCategory, includeEpg: true) + error = nil } catch { self.error = error.localizedDescription + channels = [] } } @@ -203,6 +215,6 @@ struct LiveTvScreen: View { } private struct LivePlayItem: Identifiable { - var id: Int { info.streamId } + var id: String { info.streamId } let info: IptvPlayInfo } diff --git a/apps/ios/VMC/UI/MainShell.swift b/apps/ios/VMC/UI/MainShell.swift index f775172..5e4e1cb 100644 --- a/apps/ios/VMC/UI/MainShell.swift +++ b/apps/ios/VMC/UI/MainShell.swift @@ -15,6 +15,8 @@ struct MainShell: View { NavigationStack { BrowseScreen(kind: "SERIES", title: "Series") } case .tv: NavigationStack { LiveTvScreen() } + case .events: + EventsScreen() case .search: NavigationStack { SearchScreen() } case .account: @@ -74,6 +76,7 @@ struct MainShell: View { private var visibleTabs: [AppModel.MainTab] { AppModel.MainTab.allCases.filter { tab in if tab == .tv { return app.iptvEnabled } + if tab == .events { return app.eventsEnabled } return true } } diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index 8b05eff..ca7c69a 100644 --- a/apps/ios/VMC/UI/SettingsScreen.swift +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -96,8 +96,9 @@ struct SettingsScreen: View { Section("Info") { 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("Events", value: app.eventsEnabled ? "Aan" : "Uit") } .listRowBackground(McColors.bgElevated) } diff --git a/apps/ios/VMCTV/Data/ApiClient.swift b/apps/ios/VMCTV/Data/ApiClient.swift index 5e7f8eb..36ea589 100644 --- a/apps/ios/VMCTV/Data/ApiClient.swift +++ b/apps/ios/VMCTV/Data/ApiClient.swift @@ -322,12 +322,20 @@ actor ApiClient { return wrap.channels } - func iptvPlay(streamId: Int) async throws -> IptvPlayInfo { - try await get("/api/v1/client/iptv/channels/\(streamId)/play") + func iptvPlay(streamId: String) async throws -> IptvPlayInfo { + let enc = Self.encodePathSegment(streamId) + return try await get("/api/v1/client/iptv/channels/\(enc)/play") } - func iptvEpg(streamId: Int) async throws -> IptvEpg { - try await get("/api/v1/client/iptv/channels/\(streamId)/epg") + func iptvEpg(streamId: String) async throws -> IptvEpg { + 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 diff --git a/apps/ios/VMCTV/Data/Models.swift b/apps/ios/VMCTV/Data/Models.swift index 5520a25..8a33a90 100644 --- a/apps/ios/VMCTV/Data/Models.swift +++ b/apps/ios/VMCTV/Data/Models.swift @@ -252,24 +252,69 @@ struct IptvCategory: Decodable, Identifiable, Hashable { } struct IptvChannel: Decodable, Identifiable, Hashable { - var id: Int { streamId } - let streamId: Int + var id: String { streamId } + let streamId: String let name: String let logoUrl: String? let number: Int? let categoryId: String? let nowTitle: 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 { - let streamId: Int + let streamId: String let name: String let logoUrl: String? let streamUrl: String let format: String? let fallbackStreamUrl: 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 { @@ -300,11 +345,40 @@ struct EpgProgram: Decodable, Hashable { } struct IptvEpg: Decodable, Hashable { - let streamId: Int + 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(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(_ c: KeyedDecodingContainer, 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 { diff --git a/apps/ios/VMCTV/Data/SessionStore.swift b/apps/ios/VMCTV/Data/SessionStore.swift index fe7bd83..97c7a8d 100644 --- a/apps/ios/VMCTV/Data/SessionStore.swift +++ b/apps/ios/VMCTV/Data/SessionStore.swift @@ -12,8 +12,8 @@ final class SessionStore: ObservableObject { @Published var profileAvatarId: String? @Published var profileShowAvatar: Bool = true @Published var profileAvatarUrl: String? - @Published var iptvFavoriteIds: Set = [] - @Published var lastIptvStreamId: Int? + @Published var iptvFavoriteIds: Set = [] + @Published var lastIptvStreamId: String? private let defaults = UserDefaults.standard private enum Key { @@ -37,10 +37,20 @@ final class SessionStore: ObservableObject { let showRaw = defaults.string(forKey: Key.profileShowAvatar) profileShowAvatar = showRaw != "0" profileAvatarUrl = defaults.string(forKey: Key.profileAvatarUrl) - let favs = defaults.array(forKey: Key.iptvFavorites) as? [Int] ?? [] - iptvFavoriteIds = Set(favs) - let last = defaults.integer(forKey: Key.iptvLastStream) - lastIptvStreamId = last > 0 ? last : nil + if let favStrings = defaults.array(forKey: Key.iptvFavorites) as? [String] { + iptvFavoriteIds = Set(favStrings) + } else if let favInts = defaults.array(forKey: Key.iptvFavorites) as? [Int] { + 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 } @@ -105,12 +115,12 @@ final class SessionStore: ObservableObject { } } - func isIptvFavorite(_ streamId: Int) -> Bool { + func isIptvFavorite(_ streamId: String) -> Bool { iptvFavoriteIds.contains(streamId) } @discardableResult - func toggleIptvFavorite(_ streamId: Int) -> Bool { + func toggleIptvFavorite(_ streamId: String) -> Bool { if iptvFavoriteIds.contains(streamId) { iptvFavoriteIds.remove(streamId) } else { @@ -120,7 +130,7 @@ final class SessionStore: ObservableObject { return iptvFavoriteIds.contains(streamId) } - func setLastIptvStream(_ streamId: Int) { + func setLastIptvStream(_ streamId: String) { lastIptvStreamId = streamId defaults.set(streamId, forKey: Key.iptvLastStream) } diff --git a/apps/ios/VMCTV/Playback/TVPlayerView.swift b/apps/ios/VMCTV/Playback/TVPlayerView.swift index a37b6ac..588e4d2 100644 --- a/apps/ios/VMCTV/Playback/TVPlayerView.swift +++ b/apps/ios/VMCTV/Playback/TVPlayerView.swift @@ -15,7 +15,7 @@ struct PlayRequest: Identifiable { let subtitleFileURL: URL? let logoUrl: String? /// Live-only context for zap / zenderlijst / EPG. - let liveStreamId: Int? + let liveStreamId: String? let liveChannels: [IptvChannel] let liveCategoryId: String? @@ -45,7 +45,7 @@ struct PlayRequest: Identifiable { title: String, fallback: URL?, logoUrl: String? = nil, - streamId: Int? = nil, + streamId: String? = nil, channels: [IptvChannel] = [], categoryId: String? = nil ) -> PlayRequest { @@ -89,7 +89,7 @@ struct TVPlayerView: View { @State private var progressTask: Task? @State private var hideTask: Task? @FocusState private var focusedControl: PlayerControlFocus? - @State private var liveStreamId: Int? + @State private var liveStreamId: String? @State private var liveTitle: String = "" @State private var liveLogoUrl: String? @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) } diff --git a/apps/ios/VMCTV/UI/LiveTvView.swift b/apps/ios/VMCTV/UI/LiveTvView.swift index d54b49d..02f2694 100644 --- a/apps/ios/VMCTV/UI/LiveTvView.swift +++ b/apps/ios/VMCTV/UI/LiveTvView.swift @@ -8,7 +8,7 @@ struct LiveTvView: View { @State private var channels: [IptvChannel] = [] @State private var allChannelsCache: [IptvChannel] = [] @State private var selectedCategory: String = "__all__" - @FocusState private var focusedStreamId: Int? + @FocusState private var focusedStreamId: String? @State private var epg: IptvEpg? @State private var epgLoading = false @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 defer { epgLoading = false } do {