Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/deploy-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,19 @@
env:
PUBLIC_TRAKT_ID: ${{ secrets.WEB_PUBLIC_TRAKT_CLIENT_ID }}
PUBLIC_APP_KEY: ${{ secrets.WEB_PUBLIC_APP_ANON_KEY }}
run: |

Check failure on line 55 in .github/workflows/deploy-web.yml

View workflow job for this annotation

GitHub Actions / Lint GitHub Workflows

shellcheck reported issue in this script: SC2129:style:9:1: Consider using { cmd1; cmd2; } >> file instead of individual redirects

Check failure on line 55 in .github/workflows/deploy-web.yml

View workflow job for this annotation

GitHub Actions / Lint GitHub Workflows

shellcheck reported issue in this script: SC2129:style:9:1: Consider using { cmd1; cmd2; } >> file instead of individual redirects
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
echo "::add-mask::$trakt_id"
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:
Expand Down
21 changes: 21 additions & 0 deletions web/app/dev/tv-polish/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <main style={{ padding: 20, maxWidth: 1000, margin: "auto" }}><h1>Channel artwork regression</h1>
{rows.map((row, i) => <button className="tv-event-source" key={row.name}>
<span className="tv-source-logo-fallback"><ChannelLogo channel={{ ...row, id: `fixture:${i}`, group: "Test", streamUrl: "" }} /></span>
<span><strong>{row.name}</strong><small>Test playlist</small></span>
</button>)}
</main>;
}
17 changes: 10 additions & 7 deletions web/app/tv-guide.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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; }
Expand All @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down
12 changes: 6 additions & 6 deletions web/components/livetv/ChannelLogo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,24 @@ function Logo({ channel, size }: { channel: IptvChannel; size: number }) {
const [alternatives, setAlternatives] = useState<string[]>([]);
const [loaded, setLoaded] = useState<string>();
const image = useRef<HTMLImageElement>(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 <span style={{ display: "grid", placeItems: "center", width: "100%", height: "100%", minWidth: 0 }}>
return <span className="channel-logo" style={{ display: "grid", gridTemplate: "minmax(0, 1fr) / minmax(0, 1fr)", placeItems: "center", width: "100%", height: "100%", minWidth: 0, minHeight: 0, overflow: "hidden" }}>
{(!url || loaded !== url) && <Tv size={size} style={{ gridArea: "1 / 1" }} />}
{url && <img ref={image} key={url} src={url} alt="" loading="lazy" decoding="async"
style={{ gridArea: "1 / 1", width: "100%", height: "100%", maxHeight: "100%", objectFit: "contain", opacity: loaded === url ? 1 : 0 }}
style={{ gridArea: "1 / 1", width: "100%", height: "100%", minWidth: 0, minHeight: 0, maxHeight: "100%", objectFit: "contain", opacity: loaded === url ? 1 : 0 }}
onLoad={() => setLoaded(url)} onError={() => { failChannelLogo(url); setFailed(old => [...old, url]); }} />}
</span>;
}
1 change: 1 addition & 0 deletions web/components/livetv/LiveTvScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ export function LiveTvScreen() {
<>
<div id="live-tv-player-dock" className={`livetv-detail-art ${activeChannel ? "has-live-playback" : ""}`} aria-label="Live player">
<ChannelLogo channel={selectedChannel} size={48} />
{!activeChannel && <button type="button" className="livetv-preview-play" aria-label={`Play ${selectedChannel.name}`} title={`Play ${selectedChannel.name}`} onClick={() => watchChannel(selectedChannel)}><Play size={28} fill="currentColor" /></button>}
</div>
<p className="livetv-detail-group">{selectedChannel.group || "Live TV"}</p>
<div className="livetv-channel-identity"><div className="tv-identity-logo"><ChannelLogo channel={selectedChannel} size={28} /></div><span>{selectedChannel.name}{selectedChannel.qualityLabel ? ` · ${selectedChannel.qualityLabel}` : ""}</span></div>
Expand Down
9 changes: 4 additions & 5 deletions web/components/livetv/SportsGuidePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -146,7 +146,7 @@ export function SportsGuidePane({ channels, guide, onPlay, onEnter, onOpenCatego
}}
onClick={(click) => { origin.current = click.currentTarget; setShowScore(false); setSelectedId(event.id); }}>
<div className="tv-event-art"><EventArtwork event={event} onUnavailable={() => setFailedArtwork(current => new Set([...current, event.id]))} /><span className={`tv-event-stamp${isOnAir(event, now) ? " is-on-air" : ""}`}>{stamp(event)}</span></div>
<strong>{event.title}</strong><small className="tv-event-meta"><span className="tv-event-competition">{[sport.title, event.competition].filter(Boolean).join(" · ")}</span>{isOnAir(event, now) && <span><Tv size={16} />{channelCount(availableEventChannels(event, now).length + (event.possibleChannels?.length ?? 0))}</span>}</small>
<strong>{event.title}</strong><small className="tv-event-meta"><span className="tv-event-competition">{[sport.title, event.competition].filter(Boolean).join(" · ")}</span>{isOnAir(event, now) && <span><Tv size={16} />{sportsChannelSummary(event, now)}</span>}</small>
</button>;
})}</div>
</section>)}
Expand All @@ -155,7 +155,7 @@ export function SportsGuidePane({ channels, guide, onPlay, onEnter, onOpenCatego
<header>{selected && <div className="tv-event-picker-art"><EventArtwork event={selected} /></div>}<div><p>{selected ? `${stamp(selected)} · ${guideSports.find(s => s.id === selected.sportId)!.title}` : "This event is no longer in the available guide."}</p><h2>{selected?.title ?? "Schedule changed"}</h2></div><button type="button" onClick={close} aria-label="Close"><X /></button></header>
{selected?.fixture && <div className="tv-event-details"><span>{[selected.competition, selected.fixture.venue, selected.fixture.round ? `Round ${selected.fixture.round}` : undefined].filter(Boolean).join(" · ")}</span>
{selected.fixture.homeScore !== undefined && selected.fixture.awayScore !== undefined && now - selected.fixture.observedAt < 300_000 && <button type="button" className="secondary" onClick={() => setShowScore(value => !value)}>{showScore ? `${selected.fixture.homeScore} : ${selected.fixture.awayScore}` : "Show score"}</button>}</div>}
<h3>{selected && isOnAir(selected, now) ? "Channels" : "Scheduled channels"}<span>{channelCount(sourceChannels.length)}</span></h3>
<h3>{selected && isOnAir(selected, now) ? "Channels" : "Scheduled channels"}<span>{selected ? sportsChannelSummary(selected, now) : "No channels"}</span></h3>
{!sourceChannels.length && <p className="tv-event-no-channels">No matching channels in your playlists.</p>}
<VirtualList items={sourceChannels} estimate={72} itemKey={(ch) => ch.id} label="Available channels" renderItem={(ch) =>
<button type="button" className="tv-event-source" disabled={!selected || !isOnAir(selected, now)} onClick={() => {
Expand All @@ -165,7 +165,6 @@ export function SportsGuidePane({ channels, guide, onPlay, onEnter, onOpenCatego
</section>;
}

const channelCount = (count: number) => `${count} ${count === 1 ? "channel" : "channels"}`;

function EventArtwork({ event, onUnavailable }: { event: SportsGuideEvent; onUnavailable?: () => void }) {
const [loadedUrl, setLoadedUrl] = useState<string | null>(null);
Expand Down
6 changes: 4 additions & 2 deletions web/lib/channelLogos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ export function channelLogoIndex(entries: LogoEntry[]) {
return (epgId: string | undefined, name: string): string[] => {
const exact = epgId && ids.get(epgId.trim().toLowerCase());
if (exact) return exact[3];
const prefix = /^([A-Za-z]{2,3})\s*[|:]\s*/.exec(name.trim());
// A provider qualifier is not part of the station name (e.g. UK-NOWTV).
const cleaned = name.trim().replace(/^(?:4K|8K|UHD|FHD|HD)\s*[|:]\s*/i, "");
const prefix = /^([A-Za-z]{2,3})(?:-[A-Za-z0-9]+)?\s*[|:]\s*/.exec(cleaned);
const country = prefix && countries[prefix[1].toUpperCase()];
const title = country ? name.trim().slice(prefix![0].length) : name;
const title = country ? cleaned.slice(prefix![0].length) : cleaned;
const matches = (names.get(nameKey(title)) ?? []).filter(entry => !country || (countries[entry[1].toUpperCase()] ?? entry[1].toUpperCase()) === country);
return matches.length === 1 ? matches[0][3] : [];
};
Expand Down
2 changes: 1 addition & 1 deletion web/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
function envValue(value: string | undefined, fallback = "") {
return value && !value.startsWith("$") ? value : fallback;
return value && !value.startsWith("$") && !value.includes("****") ? value : fallback;
}

const selfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === "true";
Expand Down
6 changes: 6 additions & 0 deletions web/lib/sportsGuide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export const isConfirmedLive = (event: SportsGuideEvent, now: number) => event.f
export const isOnAir = (event: SportsGuideEvent, now: number) => !["finished", "postponed"].includes(event.fixture?.status ?? "") && (isConfirmedLive(event, now) || Object.values(event.schedules ?? { fallback: event.programme }).some(p => programmeOnAir(p, now)));
export const availableEventChannels = (event: SportsGuideEvent, now: number) => event.channels.filter(ch => programmeOnAir(event.schedules?.[ch.id] ?? event.programme, now));
export const hasSportsChannels = (event: SportsGuideEvent, now: number) => (isOnAir(event, now) ? availableEventChannels(event, now) : event.channels).length > 0 || (event.possibleChannels?.length ?? 0) > 0;
export function sportsChannelSummary(event: SportsGuideEvent, now: number): string {
const matched = new Set((isOnAir(event, now) ? availableEventChannels(event, now) : event.channels).map(ch => ch.id));
const possible = new Set((event.possibleChannels ?? []).filter(ch => !matched.has(ch.id)).map(ch => ch.id));
return [matched.size ? `${matched.size} guide ${matched.size === 1 ? "match" : "matches"}` : "",
possible.size ? `${possible.size} possible` : ""].filter(Boolean).join(" · ") || "No channels";
}
export const sportsArtworkKey = (title: string) => title.normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase()
.replace(/^(live\s*[:|-]\s*|live\s+)/, "").replace(/^(football|soccer|basketball|baseball|tennis|ice hockey|american football|boxing|mma|cricket)\s*:\s*/, "")
.replace(/\b(vs\.?|versus|v\.)\s+/g, "vs ").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"setup:selfhost": "node scripts/selfhost.mjs setup",
"check:selfhost": "node scripts/selfhost.mjs check",
"test": "node --test tests/*.test.cjs",
"build": "next build",
"build": "node scripts/build.mjs",
"start": "next start",
"lint": "next lint"
},
Expand Down
Loading
Loading