stremio/apps/ios/VMCTV/UI/LiveTvView.swift
Jos Vooges | STH ac951617cb Add Live Events across API/admin/Android TV, mobile ClearKey/DASH fixes, and tvOS app.
Wire FAAB schedule-events into master-api and admin IPTV settings, add Android TV Events UI, fix mobile ClearKey/stream playback (DASH + bump 1.0.23), and scaffold the Apple TV (VMCTV) client with docs/scripts.
2026-09-18 23:34:42 +02:00

660 lines
26 KiB
Swift

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
}