SwiftUI app against master API: link, profiles, browse, live TV, favorites sync, and TestFlight-ready assets. Co-authored-by: Cursor <cursoragent@cursor.com>
206 lines
8.8 KiB
Swift
206 lines
8.8 KiB
Swift
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()
|
|
// Geen synthetische "Alles"-chip; API heeft al "Alle zenders" e.d.
|
|
categories = raw.filter { cat in
|
|
let n = cat.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
return n != "alles" && n != "all"
|
|
}
|
|
|
|
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
|
|
}
|