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:
Jos Vooges | STH 2026-09-19 03:59:51 +02:00
parent 9b6f8155d0
commit 3e2549c33a
16 changed files with 1402 additions and 50 deletions

View file

@ -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.

View file

@ -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)";

View file

@ -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

View file

@ -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
} }

View file

@ -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 {

View file

@ -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)

View file

@ -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() }

View 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 }
}
}

View file

@ -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
} }

View file

@ -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
} }
} }

View file

@ -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)
} }

View file

@ -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

View file

@ -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 {

View file

@ -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)
} }

View file

@ -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)
} }

View file

@ -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 {