mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 17:11:42 +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:
@@ -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