From e2fbb39ed0f15367ab1cb85e59491093f0039483 Mon Sep 17 00:00:00 2001 From: Arvin Date: Thu, 10 Sep 2026 19:26:46 +0200 Subject: [PATCH] fix(web): correct TV logos, sports source labels and public build configuration --- .github/workflows/deploy-web.yml | 4 ++- web/app/dev/tv-polish/page.tsx | 21 +++++++++++++++ web/app/tv-guide.css | 17 +++++++----- web/components/livetv/ChannelLogo.tsx | 12 ++++----- web/components/livetv/LiveTvScreen.tsx | 1 + web/components/livetv/SportsGuidePane.tsx | 9 +++---- web/lib/channelLogos.ts | 6 +++-- web/lib/config.ts | 2 +- web/lib/sportsGuide.ts | 6 +++++ web/package.json | 2 +- web/scripts/build.mjs | 18 +++++++++++++ web/tests/channel-logos.test.cjs | 9 ++++++- web/tests/tv-polish.test.cjs | 33 +++++++++++++++++++++++ 13 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 web/app/dev/tv-polish/page.tsx create mode 100644 web/scripts/build.mjs create mode 100644 web/tests/tv-polish.test.cjs diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 2dc390110..46e7aeb9f 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -55,7 +55,7 @@ jobs: run: | trakt_id="$PUBLIC_TRAKT_ID" app_key="$PUBLIC_APP_KEY" - if [[ -z "$trakt_id" || -z "$app_key" ]]; then + if [[ ${#trakt_id} -lt 10 || ${#app_key} -lt 40 || "$trakt_id" == *'*'* || "$app_key" == *'*'* ]]; then echo "Missing required public Trakt/cloud configuration; refusing to publish a stale-only client." >&2 exit 1 fi @@ -63,6 +63,8 @@ jobs: echo "::add-mask::$app_key" printf 'NEXT_PUBLIC_TRAKT_CLIENT_ID=%s\n' "$trakt_id" >> "$GITHUB_ENV" printf 'NEXT_PUBLIC_ARVIO_APP_ANON_KEY=%s\n' "$app_key" >> "$GITHUB_ENV" + printf 'ARVIO_BUILD_APP_ANON_KEY=%s\n' "$app_key" >> "$GITHUB_ENV" + echo 'ARVIO_VERIFY_BUILD_CONFIG=true' >> "$GITHUB_ENV" - name: Build Netlify bundle env: diff --git a/web/app/dev/tv-polish/page.tsx b/web/app/dev/tv-polish/page.tsx new file mode 100644 index 000000000..c0363146e --- /dev/null +++ b/web/app/dev/tv-polish/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; +import { headers } from "next/headers"; +import { ChannelLogo } from "@/components/livetv/ChannelLogo"; + +export const dynamic = "force-dynamic"; +export default async function Page() { + if (process.env.NODE_ENV !== "development" || process.env.ARVIO_UI_FIXTURES !== "true") notFound(); + const origin = `http://${(await headers()).get("host")}`; + const rows = [ + { name: "Square provider image", tvgId: "fixture-square", logo: `${origin}/arvio-icon-512.png` }, + { name: "NL| NPO 1 FHD", tvgId: "NPO1.nl", logo: `${origin}/arvio-icon-512.png` }, + { name: "UK-NOWTV| TNT Sports 2 FHD", tvgId: "TNTSports2.uk", logo: "" }, + { name: "Unmatched provider image", tvgId: "fixture-unmatched", logo: "" } + ]; + return

Channel artwork regression

+ {rows.map((row, i) => )} +
; +} diff --git a/web/app/tv-guide.css b/web/app/tv-guide.css index 76a3f12ae..802f9baf1 100644 --- a/web/app/tv-guide.css +++ b/web/app/tv-guide.css @@ -14,6 +14,9 @@ .player-dock-controls button, .player-dock-return { pointer-events: auto; width: 36px; height: 36px; display: grid; place-items: center; border: 0; border-radius: 4px; background: #000b; color: white; } .player-dock-return { position: absolute; inset-inline-end: 24px; top: 90px; z-index: 20; } .player-dock-controls button:focus-visible, .player-dock-return:focus-visible { outline: 2px solid white; } +.livetv-preview-play { position: absolute; inset: 0; display: grid; place-items: center; width: 100%; height: 100%; border: 0; border-radius: inherit; background: #0003; color: white; cursor: pointer; } +.livetv-preview-play svg { box-sizing: content-box; padding: 12px; background: #000b; border: 1px solid #ffffff80; border-radius: 50%; } +.livetv-preview-play:focus-visible { outline: 2px solid white; outline-offset: -2px; } .screen.livetv-shell { padding-inline: 16px; animation: none; transform: none; } .tv-drawer-scrim { display: none; } .tv-guide-workspace { --guide-channel-width: 260px; gap: 0; border-radius: 0; grid-template-columns: 300px minmax(0, 1fr); grid-template-rows: clamp(210px, 28dvh, 286px) minmax(0, 1fr); height: calc(100dvh - clamp(84px, 9vh, 108px) - 24px); min-height: 480px; flex: none; transition: grid-template-columns 240ms cubic-bezier(.2,.7,.2,1); } @@ -48,7 +51,7 @@ .tv-guide-workspace button:focus-visible, .tv-event-picker button:focus-visible { outline: 2px solid #fff; outline-offset: -2px; box-shadow: none; } .tv-guide-workspace .livetv-list { grid-column: 2; grid-row: 2; background: #080808; border: 0; border-radius: 0; min-width: 0; min-height: 0; overflow: hidden; } .tv-guide-workspace .livetv-detail { grid-column: 2; grid-row: 1; position: static; display: grid; grid-template-columns: minmax(0, 1fr) minmax(210px, 38%); align-content: start; gap: 8px 20px; background: #080808; border: 0; border-bottom: 1px solid #202020; border-radius: 0; padding: 8px 20px 16px; max-height: none; overflow: auto; } -.tv-guide-workspace .livetv-detail-art { grid-column: 2; grid-row: 1 / 5; width: 100%; height: auto; aspect-ratio: 16 / 9; max-height: none; border-radius: 4px; background: #141414; } +.tv-guide-workspace .livetv-detail-art { position: relative; grid-column: 2; grid-row: 1 / 5; width: 100%; height: auto; aspect-ratio: 16 / 9; max-height: none; border-radius: 4px; background: #141414; } .tv-guide-workspace .livetv-detail-art img { width: 100%; height: 100%; object-fit: contain; padding: 22px; } .tv-guide-workspace .livetv-detail { height: 100%; min-height: 0; } .tv-guide-workspace .livetv-detail { row-gap: 4px; } @@ -128,9 +131,9 @@ .tv-event-stamp.is-on-air::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: #ff3b30; } .tv-event-card > strong { font-size: 18px; font-weight: 500; line-height: 1.4; height: 2.8em; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; white-space: normal; max-width: 100%; overflow: hidden; overflow-wrap: anywhere; } .tv-event-card small { font-size: 15px; color: #b9c0cb; } -.tv-event-meta { display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.tv-event-meta { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 4px 8px; } .tv-event-meta span { display: flex; gap: 8px; align-items: center; font-size: 14px; } -.tv-event-meta .tv-event-competition { display: block; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tv-event-meta .tv-event-competition { display: block; min-width: 0; flex: 1 1 100%; white-space: normal; overflow-wrap: anywhere; } .tv-event-meta > span:last-child { flex-shrink: 0; } .tv-sports-empty { min-height: 340px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; color: #bbb; padding: 24px; text-align: center; } .tv-sports-empty button { width: auto; min-height: 44px; padding: 10px 16px; } @@ -145,15 +148,15 @@ @keyframes tv-picker-enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } .tv-event-picker h2 { font-size: 28px; margin: 8px 0 0; } .tv-event-picker header p { color: #b9c0cb; margin: 0; } -.tv-event-picker h3 { font-size: 22px; color: #eee; margin: 16px 0 8px; display: flex; justify-content: space-between; align-items: center; } +.tv-event-picker h3 { font-size: 22px; color: #eee; margin: 16px 0 8px; display: flex; flex-wrap: wrap; gap: 4px 12px; justify-content: space-between; align-items: center; } .tv-event-picker h3 span { font-size: 16px; color: #b9c0cb; } .tv-event-picker header button { flex-shrink: 0; width: 44px; height: 44px; border: 0; color: #eee; background: #242424; border-radius: 6px; } .tv-event-picker .virtual-list { height: min(50dvh, 520px, calc(var(--source-count, 8) * 72px)); min-height: 72px; } .tv-event-picker [data-virtual-index] { padding: 0; } .tv-event-source { display: flex; align-items: center; gap: 20px; width: 100%; height: 72px; padding: 8px 20px; background: transparent; color: #eee; border: 0; border-bottom: 1px solid #333; border-radius: 0; text-align: start; } .tv-event-source:focus-visible { background: #282828; border-radius: 4px; } -.tv-event-source img, .tv-source-logo-fallback { width: 155px; height: 52px; object-fit: contain; flex-shrink: 0; } -.tv-event-source span { display: flex; flex-direction: column; flex: 1; min-width: 0; gap: 6px; } +.tv-source-logo-fallback { width: 120px; height: 52px; overflow: hidden; flex-shrink: 0; } +.tv-event-source > span:not(.tv-source-logo-fallback) { display: flex; flex-direction: column; flex: 1; min-width: 0; gap: 6px; } .tv-event-source .tv-source-logo-fallback { flex: none; display: grid; place-items: center; } .tv-source-play { display: none; flex-shrink: 0; } .tv-event-source:focus-visible .tv-source-arrow { display: none; } @@ -204,7 +207,7 @@ .tv-event-picker h3 { font-size: 16px; } .tv-event-picker h3 span { font-size: 12px; } .tv-event-source { padding: 8px; gap: 10px; } - .tv-event-source img, .tv-source-logo-fallback { width: 48px; height: 38px; } + .tv-source-logo-fallback { width: 48px; height: 38px; } .tv-event-card strong { font-size: 16px; } .tv-event-card small { font-size: 12px; } .tv-event-meta span { font-size: 11px; } diff --git a/web/components/livetv/ChannelLogo.tsx b/web/components/livetv/ChannelLogo.tsx index 409c26bc6..a44c71d65 100644 --- a/web/components/livetv/ChannelLogo.tsx +++ b/web/components/livetv/ChannelLogo.tsx @@ -15,24 +15,24 @@ function Logo({ channel, size }: { channel: IptvChannel; size: number }) { const [alternatives, setAlternatives] = useState([]); const [loaded, setLoaded] = useState(); const image = useRef(null); - const needsDirectory = !provider || failed.includes(provider) || channelLogoFailed(provider); useEffect(() => { - if (!needsDirectory) return; let active = true; void channelLogoCandidates(channel.tvgId, channel.name).then(urls => { if (active) setAlternatives(urls); }); return () => { active = false; }; - }, [needsDirectory, channel.tvgId, channel.name]); - const url = [provider, ...alternatives].find((candidate): candidate is string => Boolean(candidate && !failed.includes(candidate) && !channelLogoFailed(candidate))); + }, [channel.tvgId, channel.name]); + // Provider-generated text placeholders return HTTP 200 too. Prefer an exact + // directory identity; ambiguous names still use the provider's own artwork. + const url = [...alternatives, provider].find((candidate): candidate is string => Boolean(candidate && !failed.includes(candidate) && !channelLogoFailed(candidate))); useEffect(() => { // Cached images can finish before React hydrates the server-rendered element. if (!url || !image.current?.complete) return; if (image.current.naturalWidth > 0) setLoaded(url); else { failChannelLogo(url); setFailed(old => old.includes(url) ? old : [...old, url]); } }, [url]); - return + return {(!url || loaded !== url) && } {url && setLoaded(url)} onError={() => { failChannelLogo(url); setFailed(old => [...old, url]); }} />} ; } diff --git a/web/components/livetv/LiveTvScreen.tsx b/web/components/livetv/LiveTvScreen.tsx index 779dade0b..44212c1bc 100644 --- a/web/components/livetv/LiveTvScreen.tsx +++ b/web/components/livetv/LiveTvScreen.tsx @@ -489,6 +489,7 @@ export function LiveTvScreen() { <>
+ {!activeChannel && }

{selectedChannel.group || "Live TV"}

{selectedChannel.name}{selectedChannel.qualityLabel ? ` · ${selectedChannel.qualityLabel}` : ""}
diff --git a/web/components/livetv/SportsGuidePane.tsx b/web/components/livetv/SportsGuidePane.tsx index 06f6714fd..cd4397778 100644 --- a/web/components/livetv/SportsGuidePane.tsx +++ b/web/components/livetv/SportsGuidePane.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { X, Tv, PanelLeft, ChevronRight, Play, RefreshCw } from "lucide-react"; -import { guideSports, isOnAir, isConfirmedLive, hasSportsChannels, availableEventChannels, sportsGuideRows, type SportsGuideEvent } from "@/lib/sportsGuide"; +import { guideSports, isOnAir, isConfirmedLive, hasSportsChannels, availableEventChannels, sportsGuideRows, sportsChannelSummary, type SportsGuideEvent } from "@/lib/sportsGuide"; import { sportsChannelKey, sportsBroadcasterKeys } from "@/lib/sportsCatalogue"; import type { InstalledAddon, IptvChannel, IptvNowNext } from "@/lib/types"; import { cachedSportsMetadata, loadSportsGuideArtwork, loadSportsMetadata, type SportsEventArtwork } from "@/lib/sportsArtwork"; @@ -85,7 +85,7 @@ export function SportsGuidePane({ channels, guide, onPlay, onEnter, onOpenCatego }, [rows.length]); const selected = illustratedEvents.find((event) => event.id === selectedId); const confirmedChannels = selected ? (isOnAir(selected, now) ? availableEventChannels(selected, now) : selected.channels) : []; - const possibleChannels = selected?.possibleChannels ?? []; + const possibleChannels = (selected?.possibleChannels ?? []).filter((ch, i, rows) => !confirmedChannels.some(match => match.id === ch.id) && rows.findIndex(other => other.id === ch.id) === i); const sourceChannels = [...confirmedChannels, ...possibleChannels]; const close = () => { dialog.current?.close(); setSelectedId(null); origin.current?.focus({ preventScroll: true }); }; useEffect(() => { @@ -146,7 +146,7 @@ export function SportsGuidePane({ channels, guide, onPlay, onEnter, onOpenCatego }} onClick={(click) => { origin.current = click.currentTarget; setShowScore(false); setSelectedId(event.id); }}>
setFailedArtwork(current => new Set([...current, event.id]))} />{stamp(event)}
- {event.title}{[sport.title, event.competition].filter(Boolean).join(" · ")}{isOnAir(event, now) && {channelCount(availableEventChannels(event, now).length + (event.possibleChannels?.length ?? 0))}} + {event.title}{[sport.title, event.competition].filter(Boolean).join(" · ")}{isOnAir(event, now) && {sportsChannelSummary(event, now)}} ; })} )} @@ -155,7 +155,7 @@ export function SportsGuidePane({ channels, guide, onPlay, onEnter, onOpenCatego
{selected &&
}

{selected ? `${stamp(selected)} · ${guideSports.find(s => s.id === selected.sportId)!.title}` : "This event is no longer in the available guide."}

{selected?.title ?? "Schedule changed"}

{selected?.fixture &&
{[selected.competition, selected.fixture.venue, selected.fixture.round ? `Round ${selected.fixture.round}` : undefined].filter(Boolean).join(" · ")} {selected.fixture.homeScore !== undefined && selected.fixture.awayScore !== undefined && now - selected.fixture.observedAt < 300_000 && }
} -

{selected && isOnAir(selected, now) ? "Channels" : "Scheduled channels"}{channelCount(sourceChannels.length)}

+

{selected && isOnAir(selected, now) ? "Channels" : "Scheduled channels"}{selected ? sportsChannelSummary(selected, now) : "No channels"}

{!sourceChannels.length &&

No matching channels in your playlists.

} ch.id} label="Available channels" renderItem={(ch) =>