import SwiftUI struct LiveTvScreen: View { @EnvironmentObject private var app: AppModel @State private var categories: [IptvCategory] = [] @State private var channels: [IptvChannel] = [] @State private var selectedCategory: String? @State private var error: String? @State private var loading = true @State private var playInfo: IptvPlayInfo? @State private var query = "" private static let lastCategoryKey = "vmc.iptv.last_category" private var filtered: [IptvChannel] { let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !q.isEmpty else { return channels } return channels.filter { $0.name.lowercased().contains(q) || ($0.nowTitle?.lowercased().contains(q) ?? false) } } var body: some View { Group { if loading && categories.isEmpty { ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error, categories.isEmpty { VStack(spacing: 12) { Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center) Button("Opnieuw") { Task { await loadCategories() } }.buttonStyle(McPrimaryButtonStyle()) } .padding() } else { VStack(spacing: 0) { HStack(spacing: 10) { Image(systemName: "magnifyingglass").foregroundStyle(McColors.textDim) TextField("Zoek zender…", text: $query) .textInputAutocapitalization(.never) .autocorrectionDisabled() .foregroundStyle(McColors.text) } .padding(.horizontal, 12) .padding(.vertical, 10) .background(McColors.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 10) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(categories) { cat in categoryChip(id: cat.id, title: cat.name) } } .padding(.horizontal, 16) .padding(.bottom, 8) } if filtered.isEmpty { ContentUnavailableView("Geen zenders", systemImage: "tv") .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(filtered) { ch in Button { Task { await play(ch) } } label: { HStack(spacing: 12) { ZStack { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.white.opacity(0.06)) .frame(width: 52, height: 52) RemoteImage(url: ch.logoUrl, contentMode: .fit, opaqueBackground: false) .frame(width: 40, height: 40) } VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { if let n = ch.number { Text("\(n)") .font(.system(size: 11, weight: .bold, design: .rounded)) .foregroundStyle(McColors.textDim) .frame(minWidth: 22, alignment: .leading) } Text(ch.name) .font(McFonts.headline) .foregroundStyle(McColors.text) .lineLimit(1) } Text(ch.nowTitle?.isEmpty == false ? ch.nowTitle! : "Live uitzending") .font(McFonts.caption) .foregroundStyle(McColors.textDim) .lineLimit(1) } Spacer(minLength: 0) Image(systemName: "play.fill") .font(.system(size: 12, weight: .bold)) .foregroundStyle(.white) .padding(10) .background(McColors.accent, in: Circle()) } .padding(.vertical, 4) } .listRowBackground(McColors.bgElevated) .listRowSeparatorTint(Color.white.opacity(0.06)) } .listStyle(.plain) .scrollContentBackground(.hidden) } } } } .background(McColors.bg.ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { BrandHeader(subtitle: "LIVE TV") } } .toolbarBackground(McColors.bg, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar) .task { await loadCategories() } .fullScreenCover(item: Binding( get: { playInfo.map { LivePlayItem(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)) .environmentObject(app) } } } private func categoryChip(id: String, title: String) -> some View { let selected = selectedCategory == id return Button { selectedCategory = id UserDefaults.standard.set(id, forKey: Self.lastCategoryKey) Task { await loadChannels() } } label: { Text(title) .font(McFonts.caption) .foregroundStyle(selected ? .white : McColors.textDim) .padding(.horizontal, 14) .padding(.vertical, 8) .background(selected ? McColors.accent : McColors.surface, in: Capsule()) } .buttonStyle(.plain) } private func loadCategories() async { loading = true error = nil do { let raw = try await app.api.loadIptvCategories() // Alleen provider-dubbel "Alles/All" weg; behoud onze "__all__" (Alle zenders) categories = raw.filter { cat in if cat.id == "__all__" { return true } let n = cat.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return n != "alles" && n != "all" && n != "alle" } let saved = UserDefaults.standard.string(forKey: Self.lastCategoryKey) if let saved, categories.contains(where: { $0.id == saved }) { selectedCategory = saved } else if let preferred = categories.first(where: { let n = $0.name.lowercased() return n.contains("alle zender") || n == "alle" || n.contains("all channel") }) { selectedCategory = preferred.id } else { selectedCategory = categories.first?.id } if let id = selectedCategory { UserDefaults.standard.set(id, forKey: Self.lastCategoryKey) } await loadChannels() loading = false } catch { self.error = error.localizedDescription loading = false } } private func loadChannels() async { do { channels = try await app.api.iptvChannels(categoryId: selectedCategory, includeEpg: true) } catch { self.error = error.localizedDescription } } private func play(_ ch: IptvChannel) async { do { playInfo = try await app.api.iptvPlay(streamId: ch.streamId) } catch { self.error = error.localizedDescription } } } private struct LivePlayItem: Identifiable { var id: Int { info.streamId } let info: IptvPlayInfo }