Restore channel list decoding for string/custom IDs, URL-encode play paths, parse DRM/heartbeat, and ship Events tab (0.5.0/12) with best-effort VLC ClearKey.
660 lines
26 KiB
Swift
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: String?
|
|
@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: String) 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
|
|
}
|