Add category backdrop fallbacks when live-event covers are missing.
Serve sport-themed 16:9 art from master-api and keep channel logos on event cards so shelves stay filled after stripping wrong live thumbs.
|
|
@ -439,6 +439,18 @@ private struct EventShelfCard: View {
|
|||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
if let logo = event.logoUrl?.nilIfBlank {
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
TitleLogo(url: logo, maxWidth: 72, maxHeight: 22)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.black.opacity(0.55), in: RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||
}
|
||||
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.clear,
|
||||
|
|
@ -539,6 +551,8 @@ struct EventDetailScreen: View {
|
|||
@State private var playInfo: IptvPlayInfo?
|
||||
@State private var playError: String?
|
||||
@State private var pulse = false
|
||||
@State private var appeared = false
|
||||
@State private var playing = false
|
||||
|
||||
private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||||
|
||||
|
|
@ -547,10 +561,18 @@ struct EventDetailScreen: View {
|
|||
if let event {
|
||||
detailBody(event)
|
||||
} else if error != nil {
|
||||
VStack(spacing: 12) {
|
||||
Text(error ?? "Fout").foregroundStyle(McColors.danger)
|
||||
Button("Terug") { dismiss() }.buttonStyle(McPrimaryButtonStyle())
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(EventsLook.soon)
|
||||
Text(error ?? "Fout")
|
||||
.font(McFonts.body)
|
||||
.foregroundStyle(Color.white.opacity(0.8))
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Terug") { dismiss() }
|
||||
.buttonStyle(McPrimaryButtonStyle())
|
||||
}
|
||||
.padding(24)
|
||||
} else {
|
||||
ProgressView("Laden…")
|
||||
.tint(EventsLook.neon)
|
||||
|
|
@ -565,6 +587,9 @@ struct EventDetailScreen: View {
|
|||
withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
|
||||
pulse = true
|
||||
}
|
||||
withAnimation(.spring(response: 0.55, dampingFraction: 0.86)) {
|
||||
appeared = true
|
||||
}
|
||||
}
|
||||
.onReceive(clock) { date in
|
||||
now = date
|
||||
|
|
@ -584,7 +609,8 @@ struct EventDetailScreen: View {
|
|||
drm: item.info.drm,
|
||||
watchSessionId: item.info.watchSessionId,
|
||||
watchKind: "event",
|
||||
watchId: eventId
|
||||
watchId: eventId,
|
||||
logoUrl: item.info.logoUrl ?? event?.logoUrl
|
||||
))
|
||||
.environmentObject(app)
|
||||
}
|
||||
|
|
@ -600,160 +626,319 @@ struct EventDetailScreen: View {
|
|||
}
|
||||
|
||||
private func detailBody(_ ev: ScheduleEvent) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
GeometryReader { geo in
|
||||
let w = geo.size.width
|
||||
let heroH = min(geo.size.height * 0.48, 380)
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
hero(ev)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(ev.name)
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(3)
|
||||
cinematicHero(ev, width: w, height: heroH)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(ev.resolvedSportLabel)
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(EventsLook.deep)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(EventsLook.neon, in: Capsule())
|
||||
EventStatusBadge(event: ev, now: now, pulse: pulse)
|
||||
}
|
||||
|
||||
let meta = EventHelpers.detailMeta(ev)
|
||||
if !meta.isEmpty {
|
||||
Text(meta)
|
||||
.font(McFonts.body)
|
||||
.foregroundStyle(Color.white.opacity(0.72))
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
let window = EventHelpers.detailTimeWindow(ev, now: now)
|
||||
if !window.isEmpty {
|
||||
Text(window)
|
||||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
if let subtitle = ev.subtitle, !subtitle.isEmpty,
|
||||
subtitle.caseInsensitiveCompare(ev.name) != .orderedSame {
|
||||
Text(subtitle)
|
||||
.font(McFonts.body)
|
||||
.foregroundStyle(Color.white.opacity(0.5))
|
||||
.lineLimit(3)
|
||||
}
|
||||
|
||||
if let description = ev.description, !description.isEmpty {
|
||||
Text(description)
|
||||
.font(McFonts.body)
|
||||
.foregroundStyle(Color.white.opacity(0.88))
|
||||
.lineSpacing(4)
|
||||
}
|
||||
|
||||
if let langs = ev.audioLanguages, !langs.isEmpty {
|
||||
Text("Audio")
|
||||
.font(McFonts.caption)
|
||||
.foregroundStyle(Color.white.opacity(0.4))
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(langs.prefix(8), id: \.self) { lang in
|
||||
Text(lang.uppercased())
|
||||
.font(McFonts.caption)
|
||||
.foregroundStyle(Color.white.opacity(0.86))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(EventsLook.panelHot, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
metaStrip(ev)
|
||||
timeBlock(ev)
|
||||
descriptionBlock(ev)
|
||||
audioBlock(ev)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 24)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.offset(y: appeared ? 0 : 18)
|
||||
}
|
||||
}
|
||||
|
||||
stickyBar(ev)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
detailTopBar(ev)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
bottomCTA(ev, bottomInset: max(geo.safeAreaInsets.bottom, 8))
|
||||
}
|
||||
.frame(width: w, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
|
||||
private func hero(_ ev: ScheduleEvent) -> some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill)
|
||||
.frame(height: 280)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipped()
|
||||
.overlay {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black.opacity(0.25), location: 0),
|
||||
.init(color: .clear, location: 0.4),
|
||||
.init(color: EventsLook.deep.opacity(0.98), location: 1),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if EventHelpers.isLive(ev, now: now) {
|
||||
LinearGradient(
|
||||
colors: [EventsLook.live.opacity(0.5), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 48)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button { dismiss() } label: {
|
||||
private func detailTopBar(_ ev: ScheduleEvent) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(10)
|
||||
.background(.black.opacity(0.45), in: Circle())
|
||||
}
|
||||
Spacer()
|
||||
if let logo = ev.logoUrl, !logo.isEmpty {
|
||||
RemoteImage(url: logo, contentMode: .fit, opaqueBackground: false)
|
||||
.frame(width: 96, height: 36)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
Text("Events")
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay(Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1))
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.frame(height: 280)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Terug naar Events")
|
||||
|
||||
private func stickyBar(_ ev: ScheduleEvent) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
if ev.isPlayable {
|
||||
Button {
|
||||
Task { await play(ev) }
|
||||
} label: {
|
||||
Label("Kijk live", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(EventsLiveButtonStyle())
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button("Terug") { dismiss() }
|
||||
.buttonStyle(McSecondaryButtonStyle())
|
||||
} else {
|
||||
Button {} label: {
|
||||
Text(EventHelpers.ctaLabel(ev, now: now))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(McSecondaryButtonStyle())
|
||||
.disabled(true)
|
||||
.opacity(0.55)
|
||||
|
||||
Button("Terug") { dismiss() }
|
||||
.buttonStyle(McPrimaryButtonStyle())
|
||||
if let provider = optionalProvider(ev) {
|
||||
Text(provider)
|
||||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(.black.opacity(0.35), in: Capsule())
|
||||
.overlay(Capsule().stroke(Color.white.opacity(0.14), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(EventsLook.panel.opacity(0.96).ignoresSafeArea(edges: .bottom))
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 8)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [EventsLook.deep.opacity(0.92), EventsLook.deep.opacity(0.55), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.allowsHitTesting(false)
|
||||
)
|
||||
}
|
||||
|
||||
private func cinematicHero(_ ev: ScheduleEvent, width: CGFloat, height: CGFloat) -> some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill)
|
||||
.frame(width: width, height: height)
|
||||
.clipped()
|
||||
.scaleEffect(appeared ? 1.0 : 1.08)
|
||||
.animation(.easeOut(duration: 0.9), value: appeared)
|
||||
|
||||
// Side vignette + bottom fade into content
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black.opacity(0.35), location: 0),
|
||||
.init(color: .clear, location: 0.28),
|
||||
.init(color: EventsLook.deep.opacity(0.55), location: 0.72),
|
||||
.init(color: EventsLook.deep, location: 1),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(width: width, height: height)
|
||||
|
||||
LinearGradient(
|
||||
colors: [EventsLook.deep.opacity(0.55), .clear],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: width * 0.45, height: height)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if EventHelpers.isLive(ev, now: now) {
|
||||
LinearGradient(
|
||||
colors: [EventsLook.live.opacity(0.45), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(width: width, height: 90)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.blendMode(.plusLighter)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
EventStatusBadge(event: ev, now: now, pulse: pulse)
|
||||
Text(ev.resolvedSportLabel.uppercased())
|
||||
.font(.system(size: 11, weight: .heavy, design: .rounded))
|
||||
.tracking(1.1)
|
||||
.foregroundStyle(EventsLook.deep)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(EventsLook.neon, in: Capsule())
|
||||
.shadow(color: EventsLook.neon.opacity(0.35), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
if let logo = ev.logoUrl, !logo.isEmpty {
|
||||
TitleLogo(url: logo, maxWidth: min(width - 48, 240), maxHeight: 56)
|
||||
} else {
|
||||
Text(ev.name)
|
||||
.font(.system(size: 30, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.shadow(color: .black.opacity(0.55), radius: 12, y: 4)
|
||||
.lineLimit(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let logo = ev.logoUrl, !logo.isEmpty {
|
||||
Text(ev.name)
|
||||
.font(.system(size: 17, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.88))
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 22)
|
||||
.frame(width: width, alignment: .leading)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.offset(y: appeared ? 0 : 24)
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
private func bottomCTA(_ ev: ScheduleEvent, bottomInset: CGFloat) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
LinearGradient(
|
||||
colors: [EventsLook.deep.opacity(0), EventsLook.deep.opacity(0.92), EventsLook.deep],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 28)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
Group {
|
||||
if ev.isPlayable {
|
||||
Button {
|
||||
Task { await play(ev) }
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
if playing {
|
||||
ProgressView().tint(EventsLook.deep)
|
||||
} else {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
}
|
||||
Text(EventHelpers.isLive(ev, now: now) ? "Kijk live" : "Nu kijken")
|
||||
.font(.system(size: 17, weight: .bold, design: .rounded))
|
||||
}
|
||||
.foregroundStyle(EventsLook.deep)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [EventsLook.neon, EventsLook.neon.opacity(0.82)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
.shadow(color: EventsLook.neon.opacity(0.45), radius: 18, y: 6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.22), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.disabled(playing)
|
||||
.scaleEffect(appeared ? 1 : 0.94)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
} else {
|
||||
Text(EventHelpers.ctaLabel(ev, now: now))
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(EventsLook.panelHot, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, max(bottomInset, 10))
|
||||
.background(EventsLook.deep)
|
||||
}
|
||||
}
|
||||
|
||||
private func metaStrip(_ ev: ScheduleEvent) -> some View {
|
||||
let meta = EventHelpers.detailMeta(ev)
|
||||
return Group {
|
||||
if !meta.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(meta.split(separator: "·").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }, id: \.self) { part in
|
||||
Text(part)
|
||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(EventsLook.panelHot, in: Capsule())
|
||||
.overlay(Capsule().stroke(Color.white.opacity(0.08), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func timeBlock(_ ev: ScheduleEvent) -> some View {
|
||||
let window = EventHelpers.detailTimeWindow(ev, now: now)
|
||||
return Group {
|
||||
if !window.isEmpty {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: EventHelpers.isLive(ev, now: now) ? "dot.radiowaves.left.and.right" : "calendar")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(EventHelpers.isLive(ev, now: now) ? EventsLook.live : EventsLook.neon)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(
|
||||
(EventHelpers.isLive(ev, now: now) ? EventsLook.liveSoft : EventsLook.neonSoft),
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(EventHelpers.isLive(ev, now: now) ? "Nu bezig" : "Speeltijd")
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
Text(window)
|
||||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(14)
|
||||
.background(EventsLook.panel.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.06), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func descriptionBlock(_ ev: ScheduleEvent) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let subtitle = ev.subtitle, !subtitle.isEmpty,
|
||||
subtitle.caseInsensitiveCompare(ev.name) != .orderedSame {
|
||||
Text(subtitle)
|
||||
.font(.system(size: 15, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(3)
|
||||
}
|
||||
if let description = ev.description, !description.isEmpty {
|
||||
Text("Over dit event")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(EventsLook.neon.opacity(0.9))
|
||||
Text(description)
|
||||
.font(McFonts.body)
|
||||
.foregroundStyle(.white.opacity(0.88))
|
||||
.lineSpacing(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func audioBlock(_ ev: ScheduleEvent) -> some View {
|
||||
Group {
|
||||
if let langs = ev.audioLanguages, !langs.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Audio")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
FlowChips(items: langs.prefix(8).map { $0.uppercased() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func optionalProvider(_ ev: ScheduleEvent) -> String? {
|
||||
let p = ev.provider.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return p.isEmpty ? nil : p.uppercased()
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
|
|
@ -774,6 +959,8 @@ struct EventDetailScreen: View {
|
|||
}
|
||||
|
||||
private func play(_ ev: ScheduleEvent) async {
|
||||
playing = true
|
||||
defer { playing = false }
|
||||
do {
|
||||
playInfo = try await app.api.scheduleEventPlay(eventId: ev.id)
|
||||
} catch {
|
||||
|
|
@ -782,24 +969,49 @@ struct EventDetailScreen: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct EventsLiveButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(McFonts.headline)
|
||||
.foregroundStyle(EventsLook.deep)
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 13)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [EventsLook.neon, EventsLook.neon.opacity(0.82)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.opacity(configuration.isPressed ? 0.8 : 1),
|
||||
in: Capsule()
|
||||
)
|
||||
.shadow(color: EventsLook.neon.opacity(0.35), radius: configuration.isPressed ? 2 : 10, y: 4)
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : 1)
|
||||
private struct FlowChips: View {
|
||||
let items: [String]
|
||||
|
||||
var body: some View {
|
||||
FlexibleChipRow(items: items)
|
||||
}
|
||||
}
|
||||
|
||||
/// Eenvoudige wrapping chip-rij zonder externe dependency.
|
||||
private struct FlexibleChipRow: View {
|
||||
let items: [String]
|
||||
|
||||
private var rows: [[String]] {
|
||||
// Simpele wrap: max ~3 per rij op smalle schermen
|
||||
var result: [[String]] = []
|
||||
var current: [String] = []
|
||||
for item in items {
|
||||
current.append(item)
|
||||
if current.count >= 3 {
|
||||
result.append(current)
|
||||
current = []
|
||||
}
|
||||
}
|
||||
if !current.isEmpty { result.append(current) }
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||
HStack(spacing: 8) {
|
||||
ForEach(row, id: \.self) { item in
|
||||
Text(item)
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(EventsLook.panelHot, in: Capsule())
|
||||
.overlay(Capsule().stroke(EventsLook.neon.opacity(0.2), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1007,7 +1219,7 @@ enum EventHelpers {
|
|||
var donors: [String: ScheduleEvent] = [:]
|
||||
for e in events {
|
||||
if e.imageLandscape == nil && (e.images ?? []).isEmpty && e.logoUrl == nil { continue }
|
||||
for key in [e.channel, e.network].compactMap({ normalizeChannel($0) }) {
|
||||
for key in donorKeys(for: e) {
|
||||
if let existing = donors[key], donorScore(existing) >= donorScore(e) { continue }
|
||||
donors[key] = e
|
||||
}
|
||||
|
|
@ -1029,7 +1241,7 @@ enum EventHelpers {
|
|||
var built: [String: ScheduleEvent] = [:]
|
||||
for sib in siblings {
|
||||
if sib.imageLandscape == nil && (sib.images ?? []).isEmpty { continue }
|
||||
for key in [sib.channel, sib.network].compactMap({ normalizeChannel($0) }) {
|
||||
for key in donorKeys(for: sib) {
|
||||
if let existing = built[key], donorScore(existing) >= donorScore(sib) { continue }
|
||||
built[key] = sib
|
||||
}
|
||||
|
|
@ -1037,8 +1249,10 @@ enum EventHelpers {
|
|||
return built
|
||||
}()
|
||||
var donor: ScheduleEvent?
|
||||
for key in [e.channel, e.network].compactMap({ normalizeChannel($0) }) {
|
||||
if let hit = map[key], hit.id != e.id {
|
||||
for key in donorKeys(for: e) {
|
||||
if let hit = map[key], hit.id != e.id,
|
||||
hit.resolvedSportBucket == e.resolvedSportBucket
|
||||
{
|
||||
donor = hit
|
||||
break
|
||||
}
|
||||
|
|
@ -1057,6 +1271,16 @@ enum EventHelpers {
|
|||
)
|
||||
}
|
||||
|
||||
/// Donor-key = sport + kanaal — voorkomt voetbal-art op Racing (zelfde Ziggo-kanaal).
|
||||
private static func donorKeys(for e: ScheduleEvent) -> [String] {
|
||||
let sport = e.resolvedSportBucket
|
||||
var keys: [String] = []
|
||||
for ch in [e.channel, e.network].compactMap({ normalizeChannel($0) }) {
|
||||
keys.append("\(sport)|\(ch)")
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
private static func donorScore(_ e: ScheduleEvent) -> Int {
|
||||
var s = 0
|
||||
if e.imageLandscape != nil { s += 20 }
|
||||
|
|
|
|||
BIN
apps/master-api/assets/event-fallbacks/basketball.jpg
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
apps/master-api/assets/event-fallbacks/cycling.jpg
Normal file
|
After Width: | Height: | Size: 291 KiB |
BIN
apps/master-api/assets/event-fallbacks/darts.jpg
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
apps/master-api/assets/event-fallbacks/football.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
apps/master-api/assets/event-fallbacks/golf.jpg
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
apps/master-api/assets/event-fallbacks/hockey.jpg
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
apps/master-api/assets/event-fallbacks/other.jpg
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
apps/master-api/assets/event-fallbacks/racing.jpg
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
apps/master-api/assets/event-fallbacks/sailing.jpg
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
apps/master-api/assets/event-fallbacks/snooker.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
apps/master-api/assets/event-fallbacks/tennis.jpg
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
apps/master-api/assets/event-fallbacks/waterpolo.jpg
Normal file
|
After Width: | Height: | Size: 322 KiB |
|
|
@ -6,7 +6,7 @@
|
|||
"dev": "tsx watch src/app.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/app.js",
|
||||
"test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts && tsx src/viewer/schedule-events-sport.test.ts && tsx src/viewer/ziggo-epg-art.test.ts && tsx src/viewer/espn-watch-art.test.ts",
|
||||
"test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts && tsx src/viewer/schedule-events-sport.test.ts && tsx src/viewer/ziggo-epg-art.test.ts && tsx src/viewer/espn-watch-art.test.ts && tsx src/viewer/event-fallbacks.test.ts",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:migrate:dev": "prisma migrate dev",
|
||||
|
|
|
|||
|
|
@ -55,6 +55,25 @@ async function main() {
|
|||
},
|
||||
});
|
||||
|
||||
// Category event-art fallbacks (public, cacheable)
|
||||
const { createReadStream, existsSync } = await import("fs");
|
||||
const pathMod = await import("path");
|
||||
const fallbacksDir = [
|
||||
pathMod.join(process.cwd(), "assets", "event-fallbacks"),
|
||||
pathMod.join(__dirname, "..", "assets", "event-fallbacks"),
|
||||
pathMod.join(__dirname, "assets", "event-fallbacks"),
|
||||
].find((d) => existsSync(d));
|
||||
app.get<{ Params: { file: string } }>("/assets/event-fallbacks/:file", async (request, reply) => {
|
||||
const file = request.params.file || "";
|
||||
if (!/^[a-z]+\.jpg$/.test(file) || !fallbacksDir) {
|
||||
return reply.code(404).send({ error: "NOT_FOUND" });
|
||||
}
|
||||
const full = pathMod.join(fallbacksDir, file);
|
||||
if (!existsSync(full)) return reply.code(404).send({ error: "NOT_FOUND" });
|
||||
reply.header("Cache-Control", "public, max-age=86400, immutable");
|
||||
return reply.type("image/jpeg").send(createReadStream(full));
|
||||
});
|
||||
|
||||
app.addHook("onSend", async (request, reply, payload) => {
|
||||
if (!request.url.startsWith("/stremio/")) {
|
||||
reply.header("X-Content-Type-Options", "nosniff");
|
||||
|
|
|
|||
60
apps/master-api/src/viewer/event-fallbacks.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import {
|
||||
applyCategoryFallbacks,
|
||||
eventFallbackUrl,
|
||||
fallbackFilenameForSport,
|
||||
isCategoryFallbackUrl,
|
||||
needsCategoryFallback,
|
||||
} from "./event-fallbacks";
|
||||
|
||||
function assert(cond: unknown, msg: string) {
|
||||
if (!cond) throw new Error(msg);
|
||||
}
|
||||
|
||||
assert(fallbackFilenameForSport("racing") === "racing.jpg", "racing file");
|
||||
assert(fallbackFilenameForSport("nope") === "other.jpg", "other file");
|
||||
assert(
|
||||
eventFallbackUrl("https://master.vonas.nl", "racing") ===
|
||||
"https://master.vonas.nl/assets/event-fallbacks/racing.jpg",
|
||||
"url"
|
||||
);
|
||||
assert(isCategoryFallbackUrl("https://x/assets/event-fallbacks/racing.jpg"), "detect");
|
||||
assert(needsCategoryFallback({ imageLandscape: null, images: [] }), "empty needs");
|
||||
assert(
|
||||
needsCategoryFallback({
|
||||
imageLandscape: "https://go.ziggosporttotaal.nl/assets/channels/light/ziggo_sport_1.png",
|
||||
images: [],
|
||||
}),
|
||||
"logo needs fallback"
|
||||
);
|
||||
assert(
|
||||
!needsCategoryFallback({
|
||||
imageLandscape: "https://cdn.example/real-art.jpg",
|
||||
images: [],
|
||||
}),
|
||||
"real art ok"
|
||||
);
|
||||
|
||||
const out = applyCategoryFallbacks(
|
||||
[
|
||||
{
|
||||
id: "1",
|
||||
name: "Moto3 samenvatting",
|
||||
channel: "Ziggo Sport 1",
|
||||
network: "Ziggo Sport 1",
|
||||
start: "2026-09-21T06:30:00Z",
|
||||
stop: "2026-09-21T07:00:00Z",
|
||||
imageLandscape: null,
|
||||
imageBackdrop: null,
|
||||
imagePortrait: null,
|
||||
logoUrl: null,
|
||||
images: ["https://go.ziggosporttotaal.nl/assets/channels/light/ziggo_sport_1.png"],
|
||||
sportBucket: "racing",
|
||||
provider: "ziggosport",
|
||||
},
|
||||
],
|
||||
"https://master.vonas.nl"
|
||||
);
|
||||
assert(out[0].imageLandscape?.includes("/assets/event-fallbacks/racing.jpg"), "set racing");
|
||||
assert(out[0].logoUrl?.includes("ziggo_sport_1"), "keep ziggo logo");
|
||||
|
||||
console.log("event-fallbacks tests ok");
|
||||
91
apps/master-api/src/viewer/event-fallbacks.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Category backdrop fallbacks when an event has no real cover art.
|
||||
* Served from /assets/event-fallbacks/{sport}.jpg on master-api.
|
||||
*/
|
||||
|
||||
import { isChannelLogoUrl, isLiveChannelThumb, type EnrichableEvent } from "./ziggo-epg-art";
|
||||
|
||||
const FALLBACK_FILE: Record<string, string> = {
|
||||
football: "football.jpg",
|
||||
racing: "racing.jpg",
|
||||
tennis: "tennis.jpg",
|
||||
golf: "golf.jpg",
|
||||
darts: "darts.jpg",
|
||||
snooker: "snooker.jpg",
|
||||
basketball: "basketball.jpg",
|
||||
waterpolo: "waterpolo.jpg",
|
||||
sailing: "sailing.jpg",
|
||||
hockey: "hockey.jpg",
|
||||
cycling: "cycling.jpg",
|
||||
other: "other.jpg",
|
||||
};
|
||||
|
||||
export function fallbackFilenameForSport(sport: string | null | undefined): string {
|
||||
const s = (sport || "other").toLowerCase();
|
||||
return FALLBACK_FILE[s] ?? FALLBACK_FILE.other;
|
||||
}
|
||||
|
||||
export function eventFallbackUrl(publicBase: string, sport: string | null | undefined): string {
|
||||
const base = publicBase.replace(/\/+$/, "");
|
||||
return `${base}/assets/event-fallbacks/${fallbackFilenameForSport(sport)}`;
|
||||
}
|
||||
|
||||
export function isCategoryFallbackUrl(url: string | null | undefined): boolean {
|
||||
if (!url) return false;
|
||||
return /\/assets\/event-fallbacks\/[a-z]+\.jpg(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
/** True when landscape is missing or only a logo / live thumb (not usable cover). */
|
||||
export function needsCategoryFallback(e: {
|
||||
imageLandscape?: string | null;
|
||||
images?: string[];
|
||||
}): boolean {
|
||||
const land = e.imageLandscape;
|
||||
if (!land) return true;
|
||||
if (isLiveChannelThumb(land)) return true;
|
||||
if (isChannelLogoUrl(land)) return true;
|
||||
if (isCategoryFallbackUrl(land)) return false;
|
||||
// Already has real art
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill empty/weak covers with sport-category backdrops and preserve channel logos in logoUrl.
|
||||
*/
|
||||
export function applyCategoryFallbacks<T extends EnrichableEvent & { provider?: string; sportBucket?: string }>(
|
||||
events: T[],
|
||||
publicBase: string
|
||||
): T[] {
|
||||
if (!publicBase) return events;
|
||||
return events.map((e) => {
|
||||
if (!needsCategoryFallback(e)) {
|
||||
// Still ensure logoUrl when we only have channel logo in images
|
||||
if (!e.logoUrl) {
|
||||
const logo = e.images.find((u) => isChannelLogoUrl(u));
|
||||
if (logo) return { ...e, logoUrl: logo };
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
const fallback = eventFallbackUrl(publicBase, e.sportBucket);
|
||||
const logoFromImages = e.images.find((u) => isChannelLogoUrl(u) && !isLiveChannelThumb(u));
|
||||
const logoFromLandscape =
|
||||
e.imageLandscape && isChannelLogoUrl(e.imageLandscape) ? e.imageLandscape : null;
|
||||
const logoUrl = e.logoUrl || logoFromLandscape || logoFromImages || null;
|
||||
|
||||
const cleaned = e.images.filter(
|
||||
(u) => !isLiveChannelThumb(u) && !isCategoryFallbackUrl(u)
|
||||
);
|
||||
const images = [fallback, ...cleaned.filter((u) => u !== fallback)];
|
||||
|
||||
return {
|
||||
...e,
|
||||
imageLandscape: fallback,
|
||||
imageBackdrop: e.imageBackdrop && !isLiveChannelThumb(e.imageBackdrop) && !isChannelLogoUrl(e.imageBackdrop)
|
||||
? e.imageBackdrop
|
||||
: fallback,
|
||||
logoUrl,
|
||||
images,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { getIptvSettings, normalizeProviderIdList } from "../settings/iptv";
|
|||
import { prisma } from "../database/client";
|
||||
import { enrichZiggoGoArt, invalidateZiggoEpgArtCache } from "./ziggo-epg-art";
|
||||
import { enrichEspnWatchArt, invalidateEspnWatchArtCache } from "./espn-watch-art";
|
||||
import { applyCategoryFallbacks } from "./event-fallbacks";
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
|
@ -824,9 +825,12 @@ export async function listScheduleEvents(
|
|||
return Date.parse(a.start) - Date.parse(b.start);
|
||||
});
|
||||
const disabledGlobal = await globalDisabledProviders(config);
|
||||
// Ziggo Gracenote + ESPN Watch art, then sport|channel gap-fill
|
||||
// Ziggo Gracenote + ESPN Watch art → sport|channel gap-fill → category backdrops
|
||||
const enriched = await enrichEspnWatchArt(await enrichZiggoGoArt(mapped));
|
||||
const withArt = fillMissingImages(enriched);
|
||||
const withArt = applyCategoryFallbacks(
|
||||
fillMissingImages(enriched),
|
||||
config.PUBLIC_URL
|
||||
);
|
||||
const events = filterByAccess(withArt, disabledGlobal, access);
|
||||
|
||||
const providers = [...new Set(events.map((e) => e.provider.toLowerCase()))].sort((a, b) =>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ RUN mkdir -p /out \
|
|||
&& cp -R apps/master-api/dist /out/dist \
|
||||
&& cp -R apps/master-api/prisma /out/prisma \
|
||||
&& cp apps/master-api/package.json /out/package.json \
|
||||
&& mkdir -p /out/assets \
|
||||
&& cp -R apps/master-api/assets/. /out/assets/ \
|
||||
&& mkdir -p /out/local-packages \
|
||||
&& cp -R packages/shared-types /out/local-packages/shared-types \
|
||||
&& cp -R packages/protocol /out/local-packages/protocol
|
||||
|
|
@ -39,6 +41,7 @@ RUN apk add --no-cache openssl wget python3 make g++
|
|||
COPY --from=builder /out/dist ./dist
|
||||
COPY --from=builder /out/prisma ./prisma
|
||||
COPY --from=builder /out/package.json ./package.json
|
||||
COPY --from=builder /out/assets ./assets
|
||||
COPY --from=builder /out/local-packages ./local-packages
|
||||
COPY --from=node-build /media-node-linux-amd64 /app/install/media-node-linux-amd64
|
||||
COPY --from=node-build /media-node-linux-arm64 /app/install/media-node-linux-arm64
|
||||
|
|
|
|||