import SwiftUI /// Premium Live TV browse — Apple HIG live-viewing + cinema-grade EPG paneel. struct LiveTvView: View { @EnvironmentObject private var app: AppModel @State private var categories: [IptvCategory] = [] @State private var channels: [IptvChannel] = [] @State private var allChannelsCache: [IptvChannel] = [] @State private var selectedCategory: String = "__all__" @FocusState private var focusedStreamId: Int? @State private var epg: IptvEpg? @State private var epgLoading = false @State private var searchText = "" @State private var error: String? @State private var loading = true @State private var channelsLoading = false @State private var playSession: LivePlaySession? @State private var clock = Date() private static let lastCategoryKey = "vmc.tv.iptv.last_category" private static let favoritesId = "__fav__" private let clockTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect() private var displayCategories: [IptvCategory] { var list = categories list.insert( IptvCategory(id: Self.favoritesId, name: "Favorieten", channelCount: app.store.iptvFavoriteIds.count), at: 0 ) return list } private var filteredChannels: [IptvChannel] { var list: [IptvChannel] if selectedCategory == Self.favoritesId { list = allChannelsCache.filter { app.store.iptvFavoriteIds.contains($0.streamId) } } else { list = channels } let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if !q.isEmpty { list = list.filter { $0.name.lowercased().contains(q) || ($0.nowTitle?.lowercased().contains(q) ?? false) || ($0.nextTitle?.lowercased().contains(q) ?? false) || ("\($0.number ?? -1)".contains(q)) } } let last = app.store.lastIptvStreamId return list.sorted { a, b in let af = app.store.isIptvFavorite(a.streamId) let bf = app.store.isIptvFavorite(b.streamId) if af != bf { return af && !bf } if a.streamId == last { return true } if b.streamId == last { return false } return (a.number ?? Int.max) < (b.number ?? Int.max) } } private var focusedChannel: IptvChannel? { filteredChannels.first { $0.streamId == focusedStreamId } ?? filteredChannels.first } 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: 18) { Image(systemName: "tv.slash") .font(.system(size: 48)) .foregroundStyle(TvColors.textDim) Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger) Button("Opnieuw") { Task { await bootstrap() } }.buttonStyle(TvPrimaryButtonStyle()) } } else { mainContent } } .background { ZStack { TvColors.bg RadialGradient( colors: [TvColors.accent.opacity(0.14), .clear], center: .topTrailing, startRadius: 40, endRadius: 900 ) } .ignoresSafeArea() } .task { await bootstrap() } .onReceive(clockTimer) { clock = $0 } .onChange(of: focusedStreamId) { _, id in guard let id else { return } Task { await loadEpg(streamId: id) } } .fullScreenCover(item: $playSession) { session in TVPlayerView(request: session.request) .environmentObject(app) } } private var mainContent: some View { VStack(alignment: .leading, spacing: 0) { headerBar categoryChips .padding(.top, 10) .padding(.bottom, 18) HStack(alignment: .top, spacing: 28) { channelColumn .frame(maxWidth: .infinity) detailPanel .frame(width: 480) .frame(maxHeight: .infinity) } .padding(.horizontal, 48) .padding(.bottom, 36) } } private var headerBar: some View { HStack(alignment: .center, spacing: 24) { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 12) { Text("LIVE TV") .font(.system(size: 38, weight: .black, design: .rounded)) .foregroundStyle(TvColors.text) LivePulseBadge() } Text("\(filteredChannels.count) zenders · \(timeLabel)") .font(TvFonts.caption) .foregroundStyle(TvColors.textDim) } Spacer(minLength: 20) HStack(spacing: 12) { Image(systemName: "magnifyingglass") .foregroundStyle(TvColors.textDim) TextField("Zoek zender of programma", text: $searchText) .font(TvFonts.body) .frame(width: 380) } .padding(.horizontal, 22) .padding(.vertical, 16) .background(.ultraThinMaterial, in: Capsule()) .overlay(Capsule().strokeBorder(Color.white.opacity(0.08), lineWidth: 1)) } .padding(.horizontal, 48) .padding(.top, 8) } private var timeLabel: String { let f = DateFormatter() f.locale = Locale(identifier: "nl_NL") f.dateFormat = "HH:mm" return f.string(from: clock) } private var categoryChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(displayCategories) { cat in Button { selectCategory(cat.id) } label: { HStack(spacing: 10) { if cat.id == Self.favoritesId { Image(systemName: "star.fill") .font(.system(size: 16, weight: .bold)) } Text(cat.name) .font(TvFonts.caption) .lineLimit(1) if let n = cat.channelCount { Text("\(n)") .font(.system(size: 16, weight: .semibold, design: .rounded)) .foregroundStyle(selectedCategory == cat.id ? .white.opacity(0.85) : TvColors.textDim) } } .foregroundStyle(selectedCategory == cat.id ? .white : TvColors.textDim) .padding(.horizontal, 22) .padding(.vertical, 14) .background( Capsule().fill(selectedCategory == cat.id ? TvColors.accent : TvColors.surface.opacity(0.9)) ) .overlay( Capsule().strokeBorder( selectedCategory == cat.id ? Color.clear : Color.white.opacity(0.08), lineWidth: 1 ) ) } .buttonStyle(FocusableCardStyle(scale: 1.06)) } } .padding(.horizontal, 48) .padding(.vertical, 8) } .scrollClipDisabled() .focusSection() } private var channelColumn: some View { VStack(alignment: .leading, spacing: 14) { HStack { Text(selectedCategory == Self.favoritesId ? "Favorieten" : "Zenders") .font(TvFonts.headline) .foregroundStyle(TvColors.text) Spacer() if channelsLoading { ProgressView().tint(.white).scaleEffect(0.7) } } if filteredChannels.isEmpty { emptyChannels } else { ScrollView { LazyVStack(spacing: 12) { ForEach(filteredChannels) { ch in Button { Task { await play(ch) } } label: { LiveChannelRowLabel( channel: ch, isFavorite: app.store.isIptvFavorite(ch.streamId), isLastWatched: app.store.lastIptvStreamId == ch.streamId ) } .buttonStyle(FocusableCardStyle(scale: 1.03)) .focused($focusedStreamId, equals: ch.streamId) .onAppear { if focusedStreamId == nil { focusedStreamId = ch.streamId } } } } .padding(.vertical, 8) .padding(.trailing, 8) } .scrollClipDisabled() .focusSection() } } } private var emptyChannels: some View { VStack(spacing: 14) { Image(systemName: selectedCategory == Self.favoritesId ? "star.slash" : "tv") .font(.system(size: 44)) .foregroundStyle(TvColors.textDim) Text(selectedCategory == Self.favoritesId ? "Nog geen favorieten" : "Geen zenders gevonden") .font(TvFonts.headline) .foregroundStyle(TvColors.textDim) Text(selectedCategory == Self.favoritesId ? "Markeer zenders met ★ in het detailpaneel." : "Probeer een andere categorie of zoekterm.") .font(TvFonts.caption) .foregroundStyle(TvColors.textDim.opacity(0.8)) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private var detailPanel: some View { LiveEpgDetailPanel( channel: focusedChannel, epg: epg, loading: epgLoading, isFavorite: focusedChannel.map { app.store.isIptvFavorite($0.streamId) } ?? false, onPlay: { if let ch = focusedChannel { Task { await play(ch) } } }, onToggleFavorite: { guard let ch = focusedChannel else { return } _ = app.store.toggleIptvFavorite(ch.streamId) } ) } private func selectCategory(_ id: String) { selectedCategory = id UserDefaults.standard.set(id, forKey: Self.lastCategoryKey) focusedStreamId = nil epg = nil Task { await loadChannels() } } private func bootstrap() async { loading = true error = nil do { let raw = try await app.api.loadIptvCategories() categories = raw.filter { cat in if cat.id == "__all__" || cat.id == Self.favoritesId { 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, displayCategories.contains(where: { $0.id == saved }) { selectedCategory = saved } else { selectedCategory = categories.first?.id ?? "__all__" } allChannelsCache = (try? await app.api.iptvChannels(categoryId: nil, includeEpg: true)) ?? [] await loadChannels() loading = false } catch { self.error = error.localizedDescription loading = false } } private func loadChannels() async { channelsLoading = true defer { channelsLoading = false } do { if selectedCategory == Self.favoritesId { if allChannelsCache.isEmpty { allChannelsCache = try await app.api.iptvChannels(categoryId: nil, includeEpg: true) } channels = allChannelsCache.filter { app.store.iptvFavoriteIds.contains($0.streamId) } } else { channels = try await app.api.iptvChannels( categoryId: selectedCategory == "__all__" ? nil : selectedCategory, includeEpg: true ) } if focusedStreamId == nil || !filteredChannels.contains(where: { $0.streamId == focusedStreamId }) { focusedStreamId = filteredChannels.first?.streamId } } catch { self.error = error.localizedDescription } } private func loadEpg(streamId: Int) async { epgLoading = true defer { epgLoading = false } do { epg = try await app.api.iptvEpg(streamId: streamId) } catch { if let ch = channels.first(where: { $0.streamId == streamId }) ?? allChannelsCache.first(where: { $0.streamId == streamId }) { epg = IptvEpg( streamId: streamId, channelName: ch.name, logoUrl: ch.logoUrl, now: ch.nowTitle.map { EpgProgram(title: $0, start: nil, end: nil) }, next: ch.nextTitle.map { EpgProgram(title: $0, start: nil, end: nil) } ) } else { epg = nil } } } private func play(_ ch: IptvChannel) async { do { let info = try await app.api.iptvPlay(streamId: ch.streamId) guard let url = URL(string: info.streamUrl) else { error = "Ongeldige stream-URL" return } app.store.setLastIptvStream(ch.streamId) let fallback = info.fallbackStreamUrl.flatMap(URL.init(string:)) let list = filteredChannels.isEmpty ? channels : filteredChannels playSession = LivePlaySession( request: .live( url: url, title: info.name, fallback: fallback, logoUrl: info.logoUrl ?? ch.logoUrl, streamId: ch.streamId, channels: list, categoryId: selectedCategory ) ) } catch { self.error = error.localizedDescription } } } private struct LiveChannelRowLabel: View { let channel: IptvChannel let isFavorite: Bool let isLastWatched: Bool @Environment(\.isFocused) private var isFocused var body: some View { HStack(spacing: 18) { ZStack { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color.white.opacity(0.06)) .frame(width: 84, height: 84) RemoteImage(url: channel.logoUrl, contentMode: .fit, opaqueBackground: false) .frame(width: 58, height: 58) } VStack(alignment: .leading, spacing: 7) { HStack(spacing: 10) { if let n = channel.number { Text(String(format: "%02d", n)) .font(.system(size: 18, weight: .bold, design: .monospaced)) .foregroundStyle(TvColors.textDim) } Text(channel.name) .font(TvFonts.headline) .foregroundStyle(.white) .lineLimit(1) if isFavorite { Image(systemName: "star.fill") .font(.system(size: 14)) .foregroundStyle(TvColors.accent) } if isLastWatched { Text("LAATST") .font(.system(size: 12, weight: .bold, design: .rounded)) .tracking(0.6) .foregroundStyle(TvColors.textDim) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.white.opacity(0.08), in: Capsule()) } } HStack(spacing: 10) { Text("NU") .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(TvColors.accent) Text(channel.nowTitle?.isEmpty == false ? channel.nowTitle! : "Live uitzending") .font(TvFonts.caption) .foregroundStyle(isFocused ? TvColors.text : TvColors.textDim) .lineLimit(1) } if let next = channel.nextTitle, !next.isEmpty { HStack(spacing: 10) { Text("STRAKS") .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(TvColors.textDim.opacity(0.7)) Text(next) .font(TvFonts.caption) .foregroundStyle(TvColors.textDim.opacity(0.85)) .lineLimit(1) } } } Spacer(minLength: 8) if isFocused { LivePulseBadge(compact: true) } Image(systemName: "play.fill") .font(.system(size: 18, weight: .bold)) .foregroundStyle(.white) .frame(width: 48, height: 48) .background(TvColors.accent, in: Circle()) .opacity(isFocused ? 1 : 0.55) } .padding(.horizontal, 20) .padding(.vertical, 16) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(isFocused ? TvColors.surface : TvColors.bgElevated.opacity(0.92)) ) .overlay { RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder( isFocused ? TvColors.accent.opacity(0.85) : Color.white.opacity(0.05), lineWidth: isFocused ? 2.5 : 1 ) } .shadow(color: isFocused ? TvColors.accent.opacity(0.35) : .clear, radius: 22, y: 8) } } private struct LiveEpgDetailPanel: View { let channel: IptvChannel? let epg: IptvEpg? let loading: Bool let isFavorite: Bool let onPlay: () -> Void let onToggleFavorite: () -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { if let channel { ZStack(alignment: .bottomLeading) { LinearGradient( colors: [TvColors.accent.opacity(0.35), TvColors.bgElevated], startPoint: .topTrailing, endPoint: .bottomLeading ) HStack(spacing: 18) { ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.black.opacity(0.35)) .frame(width: 96, height: 96) RemoteImage(url: epg?.logoUrl ?? channel.logoUrl, contentMode: .fit, opaqueBackground: false) .frame(width: 68, height: 68) } VStack(alignment: .leading, spacing: 8) { LivePulseBadge() Text(epg?.channelName ?? channel.name) .font(TvFonts.headline) .foregroundStyle(.white) .lineLimit(2) if let n = channel.number { Text("Kanaal \(n)") .font(TvFonts.caption) .foregroundStyle(TvColors.textDim) } } } .padding(24) } .frame(height: 160) .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) VStack(alignment: .leading, spacing: 22) { if loading && epg == nil { ProgressView("Programma laden…") .tint(.white) .foregroundStyle(TvColors.textDim) .font(TvFonts.caption) .padding(.top, 8) } else { programBlock( label: "Nu", title: epg?.now?.title ?? channel.nowTitle ?? "Live uitzending", time: epg?.now?.timeRangeLabel, highlight: true ) if let progress = epg?.now?.progress, progress > 0 { ProgressBar(fraction: progress, height: 6) } if let nextTitle = epg?.next?.title ?? channel.nextTitle, !nextTitle.isEmpty { programBlock( label: "Straks", title: nextTitle, time: epg?.next?.timeRangeLabel, highlight: false ) } else if epg?.now == nil && channel.nowTitle == nil { Text("Geen programmagids voor deze zender. Je kunt gewoon kijken.") .font(TvFonts.caption) .foregroundStyle(TvColors.textDim) } } Spacer(minLength: 12) HStack(spacing: 14) { Button(action: onPlay) { Label("Kijken", systemImage: "play.fill") } .buttonStyle(TvPrimaryButtonStyle()) Button(action: onToggleFavorite) { Label(isFavorite ? "Favoriet" : "Favoriet", systemImage: isFavorite ? "star.fill" : "star") } .buttonStyle(TvSecondaryButtonStyle()) } .focusSection() Text("OK om te kijken · ★ voor favorieten") .font(.system(size: 16, weight: .medium, design: .rounded)) .foregroundStyle(TvColors.textDim.opacity(0.7)) } .padding(24) } else { VStack(spacing: 12) { Spacer() Image(systemName: "sparkles.tv") .font(.system(size: 44)) .foregroundStyle(TvColors.textDim) Text("Selecteer een zender") .font(TvFonts.headline) .foregroundStyle(TvColors.textDim) Text("Programma-info verschijnt hier") .font(TvFonts.caption) .foregroundStyle(TvColors.textDim.opacity(0.7)) Spacer() } .frame(maxWidth: .infinity) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background( RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(.ultraThinMaterial) .overlay( RoundedRectangle(cornerRadius: 24, style: .continuous) .strokeBorder(Color.white.opacity(0.1), lineWidth: 1) ) ) } private func programBlock(label: String, title: String, time: String?, highlight: Bool) -> some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { Text(label.uppercased()) .font(.system(size: 14, weight: .bold, design: .rounded)) .tracking(1.2) .foregroundStyle(highlight ? TvColors.accent : TvColors.textDim) if let time { Text(time) .font(TvFonts.caption) .foregroundStyle(TvColors.textDim) } } Text(title) .font(highlight ? TvFonts.headline : TvFonts.body) .foregroundStyle(highlight ? .white : TvColors.text.opacity(0.85)) .lineLimit(3) } } } private struct LivePulseBadge: View { var compact: Bool = false @State private var pulse = false var body: some View { HStack(spacing: 8) { Circle() .fill(TvColors.accent) .frame(width: compact ? 8 : 10, height: compact ? 8 : 10) .scaleEffect(pulse ? 1.25 : 1) .opacity(pulse ? 0.55 : 1) Text("LIVE") .font(.system(size: compact ? 13 : 15, weight: .heavy, design: .rounded)) .tracking(1) .foregroundStyle(.white) } .padding(.horizontal, compact ? 10 : 12) .padding(.vertical, compact ? 5 : 6) .background(TvColors.accent.opacity(0.22), in: Capsule()) .overlay(Capsule().strokeBorder(TvColors.accent.opacity(0.55), lineWidth: 1)) .onAppear { withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { pulse = true } } } } struct LivePlaySession: Identifiable { var id: UUID { request.id } let request: PlayRequest }