mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 12:30:51 +00:00
Feat/featured stream (#4335)
**Add approved & assigned issue number here:** Resolves #4333 ## Description: Adds a small homepage panel that embeds a live Twitch stream, shown only while a configured channel is actually live. When nothing configured is live, it renders nothing. Config-gated and off by default. The client reads a served config like news.json: an on/off toggle plus the Twitch channel(s), with a bundled fallback so it stays off until the backend serves config. So OF admins control whether it is on and which channel is linked, no redeploy. First live channel in the list wins. You can drag the panel between the corners or minimise it, both persisted across refresh. Clicking it opens the channel on Twitch. It shows the live broadcast title, fetched from a third party with a clean fallback to the channel name if that is unavailable. Mainly designed OFM tournament streams, but OF can point it at its own channel for major releases. Notes: - Off by default, no impact until OF turns it on. Activation needs the matching backend config endpoint (`featured-stream.json`), which I will follow up on the infra side. - Twitch requires the embed to stay visible while playing, so "minimised" stays a small visible thumbnail and the panel pauses during a game instead of hiding (The stream can still be muted, but will keep being played, although it can be paused. This is so that the viewership still is present on twitch, and players in OF can see what is happening). Showcase of how it would look like: https://github.com/user-attachments/assets/4874bcd6-e7e8-49d8-94ab-20512ab1f71c ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: zixer._
This commit is contained in:
@@ -382,6 +382,12 @@
|
||||
<team-stats></team-stats>
|
||||
<heads-up-message></heads-up-message>
|
||||
|
||||
<!-- Featured stream: direct <body> child (NOT inside the in-[.in-game]:hidden
|
||||
home container) so the Twitch iframe is never display:none'd during a game,
|
||||
which would unmount the player and break ToS-compliant playback. The
|
||||
component is position:fixed and self-manages visibility (pauses in-game). -->
|
||||
<featured-stream></featured-stream>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script>
|
||||
// Remove preload class after everything is loaded
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"channels": []
|
||||
}
|
||||
@@ -518,6 +518,12 @@
|
||||
"unit_voluntarily_deleted": "Unit voluntarily deleted",
|
||||
"wants_to_renew_alliance": "{name} wants to renew your alliance"
|
||||
},
|
||||
"featured_stream": {
|
||||
"expand": "Expand",
|
||||
"live": "LIVE",
|
||||
"minimize": "Minimize",
|
||||
"open_on_twitch": "Open {channel} on Twitch"
|
||||
},
|
||||
"flag_input": {
|
||||
"button_title": "Pick a flag!",
|
||||
"search_flag": "Search...",
|
||||
|
||||
+25
-1
@@ -1,11 +1,13 @@
|
||||
import featuredStreamFallback from "resources/featured-stream.json";
|
||||
import newsItemsFallback from "resources/news.json";
|
||||
import { z } from "zod";
|
||||
import type { NewsItem } from "../core/ApiSchemas";
|
||||
import type { FeaturedStreamConfig, NewsItem } from "../core/ApiSchemas";
|
||||
import {
|
||||
ClaimAllRewardsResponse,
|
||||
ClaimAllRewardsResponseSchema,
|
||||
ClaimRewardResponse,
|
||||
ClaimRewardResponseSchema,
|
||||
FeaturedStreamSchema,
|
||||
NewsItemSchema,
|
||||
PlayerGameModeFilter,
|
||||
PlayerGameTypeFilter,
|
||||
@@ -802,3 +804,25 @@ export async function getNews(): Promise<NewsItem[]> {
|
||||
return newsItemsFallback as NewsItem[];
|
||||
}
|
||||
}
|
||||
|
||||
// Featured-stream config, served like news.json (API-hosted JSON + bundled fallback).
|
||||
export async function getFeaturedStream(): Promise<FeaturedStreamConfig> {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/featured-stream.json`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
console.warn("getFeaturedStream: unexpected status", res.status);
|
||||
return FeaturedStreamSchema.parse(featuredStreamFallback);
|
||||
}
|
||||
const parsed = FeaturedStreamSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
console.warn("getFeaturedStream: Zod validation failed", parsed.error);
|
||||
return FeaturedStreamSchema.parse(featuredStreamFallback);
|
||||
}
|
||||
return parsed.data;
|
||||
} catch (err) {
|
||||
console.warn("getFeaturedStream: request failed, using fallback", err);
|
||||
return FeaturedStreamSchema.parse(featuredStreamFallback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { getFeaturedStream } from "./Api";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
// Homepage "featured stream" panel: embeds a Twitch channel and shows ONLY while it is
|
||||
// actually live, then hides. Config comes from getFeaturedStream() (a JSON the API serves
|
||||
// like news.json, with a bundled fallback in resources/featured-stream.json): an `enabled`
|
||||
// toggle + a `channels` list. Disabled or no channels = feature off (renders nothing).
|
||||
// With several channels, the first one that is live wins (e.g. the OFM channel for
|
||||
// tournaments + the OF channel for releases).
|
||||
//
|
||||
// Live detection is client-side via the Twitch Embed SDK (no backend, no API secret).
|
||||
// Quirks handled: an offline-at-load channel fires READY -> ENDED (no OFFLINE), so we read
|
||||
// the initial state synchronously with getEnded() on READY; events from a superseded
|
||||
// player are ignored via a mount generation counter; and if every channel is offline we
|
||||
// re-check periodically so the panel still appears when a stream goes live later.
|
||||
|
||||
interface TwitchPlayer {
|
||||
addEventListener(event: string, cb: () => void): void;
|
||||
getEnded(): boolean;
|
||||
play(): void;
|
||||
pause(): void;
|
||||
setMuted(muted: boolean): void;
|
||||
destroy?(): void;
|
||||
}
|
||||
interface TwitchPlayerCtor {
|
||||
new (el: HTMLElement, opts: Record<string, unknown>): TwitchPlayer;
|
||||
READY: string;
|
||||
ONLINE: string;
|
||||
OFFLINE: string;
|
||||
ENDED: string;
|
||||
}
|
||||
interface TwitchGlobal {
|
||||
Player: TwitchPlayerCtor;
|
||||
}
|
||||
declare global {
|
||||
interface Window {
|
||||
Twitch?: TwitchGlobal;
|
||||
}
|
||||
}
|
||||
|
||||
export type Corner = "tl" | "tr" | "bl" | "br";
|
||||
const CORNER_KEY = "featured-stream-corner";
|
||||
const MIN_KEY = "featured-stream-minimized";
|
||||
const RECHECK_MS = 60_000; // re-probe interval when every channel is offline
|
||||
|
||||
const CORNER_CLASS: Record<Corner, string> = {
|
||||
tl: "top-4 left-4",
|
||||
tr: "top-4 right-4",
|
||||
bl: "bottom-4 left-4",
|
||||
br: "bottom-4 right-4",
|
||||
};
|
||||
|
||||
// Nearest corner for a panel centered at (cx, cy) within a vw x vh viewport. Pure for tests.
|
||||
export function cornerFromCenter(
|
||||
cx: number,
|
||||
cy: number,
|
||||
vw: number,
|
||||
vh: number,
|
||||
): Corner {
|
||||
return `${cy > vh / 2 ? "b" : "t"}${cx > vw / 2 ? "r" : "l"}` as Corner;
|
||||
}
|
||||
|
||||
const SDK_SRC = "https://embed.twitch.tv/embed/v1.js";
|
||||
let sdkPromise: Promise<TwitchGlobal> | undefined;
|
||||
function loadTwitchSdk(): Promise<TwitchGlobal> {
|
||||
if (sdkPromise) return sdkPromise;
|
||||
sdkPromise = new Promise((resolve, reject) => {
|
||||
if (window.Twitch?.Player) return resolve(window.Twitch);
|
||||
const s = document.createElement("script");
|
||||
s.src = SDK_SRC;
|
||||
s.async = true;
|
||||
s.onload = () =>
|
||||
window.Twitch?.Player
|
||||
? resolve(window.Twitch)
|
||||
: reject(new Error("Twitch SDK missing"));
|
||||
s.onerror = () => reject(new Error("Twitch SDK failed to load"));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return sdkPromise;
|
||||
}
|
||||
|
||||
@customElement("featured-stream")
|
||||
export class FeaturedStream extends LitElement {
|
||||
@state() private live = false;
|
||||
@state() private inGame = false;
|
||||
@state() private minimized = false;
|
||||
@state() private corner: Corner = "br"; // which screen corner the panel snaps to
|
||||
@state() private dragPos: { x: number; y: number } | null = null; // free pos while dragging
|
||||
|
||||
private channels: string[] = [];
|
||||
private idx = 0;
|
||||
private player?: TwitchPlayer;
|
||||
private mountGen = 0; // bumped each mount; events from older mounts are ignored
|
||||
private recheckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private dragOff = { x: 0, y: 0 };
|
||||
private dragStart = { x: 0, y: 0 };
|
||||
private dragging = false;
|
||||
private dragMoved = false;
|
||||
|
||||
// Light DOM so Tailwind classes apply (matches HomepagePromos).
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const saved = localStorage.getItem(CORNER_KEY);
|
||||
if (saved === "tl" || saved === "tr" || saved === "bl" || saved === "br")
|
||||
this.corner = saved;
|
||||
this.minimized = localStorage.getItem(MIN_KEY) === "true";
|
||||
document.addEventListener("join-lobby", this.onJoin);
|
||||
document.addEventListener("leave-lobby", this.onLeave);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener("join-lobby", this.onJoin);
|
||||
document.removeEventListener("leave-lobby", this.onLeave);
|
||||
if (this.recheckTimer) clearTimeout(this.recheckTimer);
|
||||
this.recheckTimer = null;
|
||||
this.teardownPlayer();
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
// Channels come from the served config (like news.json), with a bundled fallback.
|
||||
const cfg = await getFeaturedStream();
|
||||
if (!cfg.enabled || cfg.channels.length === 0) return; // off / nothing configured
|
||||
this.channels = cfg.channels;
|
||||
this.requestUpdate(); // channels is not reactive; force the card (+ mount node) to render
|
||||
await this.updateComplete;
|
||||
void this.start();
|
||||
}
|
||||
|
||||
private onJoin = () => {
|
||||
this.inGame = true; // hide while in a lobby/game
|
||||
// Stop probing while in a game: a pending recheck (or a stream coming online) must not
|
||||
// mount a fresh autoplaying player behind the hidden panel — an obscured embed (Twitch
|
||||
// ToS). Cancel the recheck and pause the current player; we re-probe on leave.
|
||||
if (this.recheckTimer) {
|
||||
clearTimeout(this.recheckTimer);
|
||||
this.recheckTimer = null;
|
||||
}
|
||||
try {
|
||||
this.player?.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
private onLeave = () => {
|
||||
this.inGame = false;
|
||||
// Back on the homepage: re-probe from the top so liveness is fresh and the panel only
|
||||
// reappears (and starts streaming) if a channel is actually live right now.
|
||||
if (this.recheckTimer) {
|
||||
clearTimeout(this.recheckTimer);
|
||||
this.recheckTimer = null;
|
||||
}
|
||||
this.idx = 0;
|
||||
void this.start();
|
||||
};
|
||||
|
||||
private start = async () => {
|
||||
let Twitch: TwitchGlobal;
|
||||
try {
|
||||
Twitch = await loadTwitchSdk();
|
||||
} catch (e) {
|
||||
console.error("featured-stream: Twitch SDK load failed", e);
|
||||
return;
|
||||
}
|
||||
this.mountPlayer(Twitch, this.idx);
|
||||
};
|
||||
|
||||
private mountPlayer(Twitch: TwitchGlobal, i: number) {
|
||||
if (this.inGame) return; // never mount an autoplaying embed behind the hidden panel
|
||||
const host = this.querySelector(
|
||||
"#featured-stream-mount",
|
||||
) as HTMLElement | null;
|
||||
const channel = this.channels[i];
|
||||
if (!host || !channel) return;
|
||||
this.teardownPlayer(); // destroy the previous player so its listeners can't fire
|
||||
host.innerHTML = "";
|
||||
const gen = ++this.mountGen;
|
||||
const fresh = () => gen === this.mountGen; // ignore events from a superseded mount
|
||||
const player = new Twitch.Player(host, {
|
||||
channel,
|
||||
parent: [window.location.hostname], // bare host; self-adapts to any domain/subdomain
|
||||
muted: true, // required for autoplay
|
||||
autoplay: true,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
});
|
||||
this.player = player;
|
||||
const P = Twitch.Player;
|
||||
player.addEventListener(P.READY, () => {
|
||||
if (!fresh()) return;
|
||||
// offline-at-load = READY -> ENDED (no OFFLINE); read state synchronously here
|
||||
if (player.getEnded()) this.advance(Twitch);
|
||||
else this.setLive(i);
|
||||
});
|
||||
player.addEventListener(P.ONLINE, () => fresh() && this.setLive(i));
|
||||
player.addEventListener(P.OFFLINE, () => fresh() && this.advance(Twitch));
|
||||
player.addEventListener(P.ENDED, () => fresh() && this.advance(Twitch));
|
||||
}
|
||||
|
||||
private teardownPlayer() {
|
||||
try {
|
||||
this.player?.destroy?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.player = undefined;
|
||||
}
|
||||
|
||||
private setLive(i: number) {
|
||||
this.idx = i;
|
||||
this.live = true;
|
||||
this.kickPlay();
|
||||
}
|
||||
|
||||
// Current channel offline -> try the next configured one. Bumping mountGen first makes
|
||||
// the current player's remaining events no-ops (so a duplicate OFFLINE/ENDED can't skip
|
||||
// a channel). If all channels are offline, re-probe later so the panel can still appear.
|
||||
private advance(Twitch: TwitchGlobal) {
|
||||
this.mountGen++;
|
||||
this.live = false;
|
||||
this.idx++;
|
||||
if (this.idx < this.channels.length) {
|
||||
this.mountPlayer(Twitch, this.idx);
|
||||
} else {
|
||||
this.teardownPlayer();
|
||||
this.scheduleRecheck(Twitch);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRecheck(Twitch: TwitchGlobal) {
|
||||
if (this.recheckTimer) clearTimeout(this.recheckTimer);
|
||||
this.recheckTimer = setTimeout(() => {
|
||||
this.recheckTimer = null;
|
||||
this.idx = 0;
|
||||
this.mountPlayer(Twitch, 0);
|
||||
}, RECHECK_MS);
|
||||
}
|
||||
|
||||
// Autoplay can be blocked while the panel is hidden; once it's visible, nudge playback.
|
||||
// Do NOT touch mute here — respect the user's choice (initial load is muted for
|
||||
// autoplay; if they unmuted to listen, it stays unmuted even when minimized).
|
||||
private kickPlay() {
|
||||
if (!this.present()) return;
|
||||
void this.updateComplete.then(() => {
|
||||
try {
|
||||
this.player?.play();
|
||||
} catch {
|
||||
/* user can press play in the embed */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// present = rendered & playing (live, not in a game). minimized is a sub-state that
|
||||
// keeps the player mounted and streaming, just visually collapsed to the header bar.
|
||||
private present(): boolean {
|
||||
return this.live && !this.inGame;
|
||||
}
|
||||
|
||||
private openStream = () => {
|
||||
const channel = this.channels[this.idx];
|
||||
if (channel)
|
||||
window.open(`https://twitch.tv/${channel}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
// The header is a drag handle: a click (no drag) opens the stream, a drag (past a small
|
||||
// threshold) snaps the panel to the nearest corner. Buttons inside are excluded so the
|
||||
// open/minimize controls work; the Twitch player is a separate iframe (its controls are
|
||||
// never intercepted).
|
||||
private onDragDown = (e: PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
this.dragOff = { x: 0, y: 0 };
|
||||
const card = this.querySelector(
|
||||
"#featured-stream-card",
|
||||
) as HTMLElement | null;
|
||||
if (card) {
|
||||
const r = card.getBoundingClientRect();
|
||||
this.dragOff = { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||
}
|
||||
this.dragStart = { x: e.clientX, y: e.clientY };
|
||||
this.dragging = true;
|
||||
this.dragMoved = false;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
private onDragMove = (e: PointerEvent) => {
|
||||
if (!this.dragging) return;
|
||||
if (
|
||||
!this.dragMoved &&
|
||||
Math.hypot(e.clientX - this.dragStart.x, e.clientY - this.dragStart.y) < 5
|
||||
)
|
||||
return; // below threshold -> still a click, not a drag
|
||||
this.dragMoved = true;
|
||||
this.dragPos = {
|
||||
x: e.clientX - this.dragOff.x,
|
||||
y: e.clientY - this.dragOff.y,
|
||||
};
|
||||
};
|
||||
private onDragUp = () => {
|
||||
if (!this.dragging) return;
|
||||
this.dragging = false;
|
||||
if (this.dragMoved && this.dragPos) {
|
||||
const card = this.querySelector(
|
||||
"#featured-stream-card",
|
||||
) as HTMLElement | null;
|
||||
const cx = this.dragPos.x + (card?.offsetWidth ?? 360) / 2;
|
||||
const cy = this.dragPos.y + (card?.offsetHeight ?? 200) / 2;
|
||||
this.corner = cornerFromCenter(
|
||||
cx,
|
||||
cy,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
);
|
||||
localStorage.setItem(CORNER_KEY, this.corner);
|
||||
this.dragPos = null;
|
||||
} else {
|
||||
this.openStream();
|
||||
}
|
||||
};
|
||||
|
||||
private toggleMinimize = () => {
|
||||
this.minimized = !this.minimized;
|
||||
localStorage.setItem(MIN_KEY, String(this.minimized));
|
||||
this.kickPlay(); // resume playback after the resize either way
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.channels.length) return html``;
|
||||
const channel = this.channels[this.idx] ?? "";
|
||||
const min = this.minimized;
|
||||
// Twitch pauses the player when it's off-screen/clipped (and hiding the embed violates
|
||||
// Twitch ToS), so "minimized" stays a small but still-visible corner thumbnail that
|
||||
// keeps streaming. z above the footer (z-50) and content so it overlays everything.
|
||||
return html`
|
||||
<div
|
||||
id="featured-stream-card"
|
||||
class="fixed z-[45000] overflow-hidden rounded-lg bg-black/95 shadow-2xl ring-1 ring-white/10 ${this
|
||||
.dragPos
|
||||
? ""
|
||||
: "transition-all duration-300 " +
|
||||
CORNER_CLASS[this.corner]} ${this.present()
|
||||
? "opacity-100"
|
||||
: "pointer-events-none opacity-0"} ${min
|
||||
? "w-[360px]"
|
||||
: "w-[clamp(340px,40vw,720px)] max-w-[92vw]"}"
|
||||
style=${this.dragPos
|
||||
? `left:${this.dragPos.x}px;top:${this.dragPos.y}px`
|
||||
: ""}
|
||||
aria-hidden=${this.present() ? "false" : "true"}
|
||||
>
|
||||
<div
|
||||
class="flex h-9 cursor-move touch-none items-center justify-between gap-2 px-2 text-white select-none"
|
||||
@pointerdown=${this.onDragDown}
|
||||
@pointermove=${this.onDragMove}
|
||||
@pointerup=${this.onDragUp}
|
||||
@pointercancel=${this.onDragUp}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 items-center gap-2 text-sm font-semibold hover:underline"
|
||||
aria-label=${translateText("featured_stream.open_on_twitch", {
|
||||
channel,
|
||||
})}
|
||||
@click=${this.openStream}
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 shrink-0 animate-pulse rounded-full bg-red-500"
|
||||
></span>
|
||||
<span class="shrink-0"
|
||||
>${translateText("featured_stream.live")}</span
|
||||
>
|
||||
<span class="truncate font-bold">${channel}</span>
|
||||
</button>
|
||||
<button
|
||||
class="shrink-0 px-1 text-lg leading-none text-white/70 hover:text-white"
|
||||
aria-label=${translateText(
|
||||
min ? "featured_stream.expand" : "featured_stream.minimize",
|
||||
)}
|
||||
@click=${this.toggleMinimize}
|
||||
>
|
||||
${min ? "⤢" : "–"}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="featured-stream-mount"
|
||||
class="aspect-video w-full bg-black"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import "./CosmeticsModal";
|
||||
import { CosmeticsModal } from "./CosmeticsModal";
|
||||
import { updateCrazyGamesNavButton } from "./CrazyGamesAccountButton";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import "./FeaturedStream";
|
||||
import "./FlagInput";
|
||||
import { FlagInput } from "./FlagInput";
|
||||
import "./FlagInputModal";
|
||||
|
||||
@@ -433,3 +433,24 @@ export const NewsItemSchema = z.object({
|
||||
type: z.enum(["tournament", "tutorial", "announcement"]).or(z.string()),
|
||||
});
|
||||
export type NewsItem = z.infer<typeof NewsItemSchema>;
|
||||
|
||||
// Config for the homepage featured-stream panel, served like news.json (a JSON the API
|
||||
// hosts, with a bundled fallback). `enabled` is the on/off signal; `channels` is the
|
||||
// Twitch channel logins to show (first one that is live wins). Invalid/garbage logins are
|
||||
// dropped individually (trimmed, matched to the Twitch login format) rather than failing
|
||||
// the whole config closed — one bad entry must not silently disable the feature for every
|
||||
// client; the valid channels still flow through.
|
||||
const TWITCH_LOGIN = /^[a-zA-Z0-9_]{3,25}$/;
|
||||
export const FeaturedStreamSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
channels: z
|
||||
.array(z.unknown())
|
||||
.default([])
|
||||
.transform((cs) =>
|
||||
cs
|
||||
.filter((c): c is string => typeof c === "string")
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => TWITCH_LOGIN.test(c)),
|
||||
),
|
||||
});
|
||||
export type FeaturedStreamConfig = z.infer<typeof FeaturedStreamSchema>;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import featuredStream from "../../../resources/featured-stream.json";
|
||||
import { getFeaturedStream } from "../../../src/client/Api";
|
||||
import { cornerFromCenter } from "../../../src/client/FeaturedStream";
|
||||
import { FeaturedStreamSchema } from "../../../src/core/ApiSchemas";
|
||||
|
||||
describe("FeaturedStream", () => {
|
||||
describe("bundled config (resources/featured-stream.json)", () => {
|
||||
it("validates against the schema", () => {
|
||||
expect(FeaturedStreamSchema.safeParse(featuredStream).success).toBe(true);
|
||||
});
|
||||
|
||||
it("is off by default (no channel shown unless OF turns it on)", () => {
|
||||
const cfg = FeaturedStreamSchema.parse(featuredStream);
|
||||
expect(cfg.enabled).toBe(false);
|
||||
expect(cfg.channels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeaturedStreamSchema", () => {
|
||||
it("defaults to disabled with no channels", () => {
|
||||
const cfg = FeaturedStreamSchema.parse({});
|
||||
expect(cfg.enabled).toBe(false);
|
||||
expect(cfg.channels).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts enabled with a channel list", () => {
|
||||
const cfg = FeaturedStreamSchema.parse({
|
||||
enabled: true,
|
||||
channels: ["openfrontmasters", "openfront"],
|
||||
});
|
||||
expect(cfg.enabled).toBe(true);
|
||||
expect(cfg.channels).toEqual(["openfrontmasters", "openfront"]);
|
||||
});
|
||||
|
||||
it("drops bad channel entries instead of failing the whole config", () => {
|
||||
// Non-strings, too-short, illegal chars, and full URLs are all dropped
|
||||
// individually so one garbage entry can't silently disable the feature for
|
||||
// every client; the valid login still comes through.
|
||||
const cfg = FeaturedStreamSchema.parse({
|
||||
enabled: true,
|
||||
channels: [
|
||||
1,
|
||||
"ab",
|
||||
"has space",
|
||||
"bad!",
|
||||
"https://twitch.tv/x",
|
||||
"openfrontmasters",
|
||||
],
|
||||
});
|
||||
expect(cfg.channels).toEqual(["openfrontmasters"]);
|
||||
});
|
||||
|
||||
it("accepts a valid channel login", () => {
|
||||
expect(
|
||||
FeaturedStreamSchema.safeParse({ channels: ["eslcs", "es_l_2"] })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeaturedStream", () => {
|
||||
const off = { enabled: false, channels: [] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const stubFetch = (impl: () => unknown) =>
|
||||
vi.stubGlobal("fetch", vi.fn(impl));
|
||||
|
||||
it("returns the served config on HTTP 200 with valid JSON", async () => {
|
||||
stubFetch(() =>
|
||||
Promise.resolve({
|
||||
status: 200,
|
||||
json: async () => ({ enabled: true, channels: ["eslcs"] }),
|
||||
}),
|
||||
);
|
||||
const cfg = await getFeaturedStream();
|
||||
expect(cfg).toEqual({ enabled: true, channels: ["eslcs"] });
|
||||
});
|
||||
|
||||
it("falls back to the bundled config on a non-200 status", async () => {
|
||||
stubFetch(() => Promise.resolve({ status: 404, json: async () => ({}) }));
|
||||
expect(await getFeaturedStream()).toEqual(off);
|
||||
});
|
||||
|
||||
it("drops invalid channels instead of failing the whole config", async () => {
|
||||
stubFetch(() =>
|
||||
Promise.resolve({
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
enabled: true,
|
||||
channels: ["valid_chan", "bad name!", "x"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// "bad name!" (space/!) and "x" (too short) are dropped; the valid one stays,
|
||||
// so one garbage entry can't silently disable the feature for everyone.
|
||||
expect(await getFeaturedStream()).toEqual({
|
||||
enabled: true,
|
||||
channels: ["valid_chan"],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back when the request rejects (network error)", async () => {
|
||||
stubFetch(() => Promise.reject(new Error("network down")));
|
||||
expect(await getFeaturedStream()).toEqual(off);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cornerFromCenter", () => {
|
||||
it("maps each quadrant to the nearest corner", () => {
|
||||
expect(cornerFromCenter(100, 100, 1000, 800)).toBe("tl");
|
||||
expect(cornerFromCenter(900, 100, 1000, 800)).toBe("tr");
|
||||
expect(cornerFromCenter(100, 700, 1000, 800)).toBe("bl");
|
||||
expect(cornerFromCenter(900, 700, 1000, 800)).toBe("br");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user