stremio/apps/ios/VMC/UI/LiveTvScreen.swift
Jos Vooges | STH 252bce838b Add native VMC iOS client with VLC playback.
SwiftUI app against master API: link, profiles, browse, live TV, favorites sync, and TestFlight-ready assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 19:16:29 +02:00

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
}