feat(featured-stream): mobile flick-to-dismiss (#4673)

On touch devices, dragging the featured-stream panel off the edge of the
screen dismisses it for the current page visit. Desktop is unchanged:
the same drag still snaps to the nearest corner, exactly as today.

On small screens the panel can get in the way. This gives mobile users a
quick, familiar "flick it away" gesture to hide it, without a persistent
opt-out they might set once and forget.

- **Touch only**, gated on `matchMedia("(pointer: coarse)")`. Mouse
users keep corner-snap.
- **Trigger:** on drag release, if the panel's center has left the
viewport (more than half of it is off-screen), it is dismissed.
- **Not persisted.** Dismiss is in-memory only, so a refresh or the next
visit brings the panel back. It's a "not now", not a permanent hide.
- Dismiss tears down the Twitch player and stops the offline re-probe,
so nothing keeps streaming behind a hidden panel, and leaving a game
does not resurrect it.

Added unit coverage for the new pure `isOffFrame(cx, cy, vw, vh)` helper
(inside the viewport vs past each edge). Existing FeaturedStream tests
still pass.

Follow-up to #4335 (now merged).
This commit is contained in:
Zixer1
2026-07-23 13:13:36 -07:00
committed by evanpelle
parent 27e45b6d93
commit 7d1c2edfb6
2 changed files with 74 additions and 4 deletions
+55 -3
View File
@@ -62,6 +62,26 @@ export function cornerFromCenter(
return `${cy > vh / 2 ? "b" : "t"}${cx > vw / 2 ? "r" : "l"}` as Corner;
}
// True once a panel centered at (cx, cy) has been dragged past an edge of the vw x vh
// viewport (its center is outside it), i.e. more than half of it is off-screen. Pure for tests.
export function isOffFrame(
cx: number,
cy: number,
vw: number,
vh: number,
): boolean {
return cx < 0 || cx > vw || cy < 0 || cy > vh;
}
// Touch devices report a coarse pointer; flick-to-dismiss is enabled only there (on desktop
// the same drag just snaps to the nearest corner).
function isCoarsePointer(): boolean {
return (
typeof window.matchMedia === "function" &&
window.matchMedia("(pointer: coarse)").matches
);
}
const SDK_SRC = "https://embed.twitch.tv/embed/v1.js";
let sdkPromise: Promise<TwitchGlobal> | undefined;
function loadTwitchSdk(): Promise<TwitchGlobal> {
@@ -88,6 +108,7 @@ export class FeaturedStream extends LitElement {
@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
@state() private dismissed = false; // mobile flick-off; page-visit only, not persisted
private channels: string[] = [];
private idx = 0;
@@ -150,6 +171,7 @@ export class FeaturedStream extends LitElement {
};
private onLeave = () => {
this.inGame = false;
if (this.dismissed) return; // dismissed for this page visit: don't resurrect it
// 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) {
@@ -172,7 +194,7 @@ export class FeaturedStream extends LitElement {
};
private mountPlayer(Twitch: TwitchGlobal, i: number) {
if (this.inGame) return; // never mount an autoplaying embed behind the hidden panel
if (this.inGame || this.dismissed) return; // never mount behind a hidden/dismissed panel
const host = this.querySelector(
"#featured-stream-mount",
) as HTMLElement | null;
@@ -310,6 +332,14 @@ export class FeaturedStream extends LitElement {
) as HTMLElement | null;
const cx = this.dragPos.x + (card?.offsetWidth ?? 360) / 2;
const cy = this.dragPos.y + (card?.offsetHeight ?? 200) / 2;
// Touch only: flicking the panel off the edge dismisses it for this page visit.
if (
isCoarsePointer() &&
isOffFrame(cx, cy, window.innerWidth, window.innerHeight)
) {
this.dismiss();
return;
}
this.corner = cornerFromCenter(
cx,
cy,
@@ -323,6 +353,28 @@ export class FeaturedStream extends LitElement {
}
};
// A canceled pointer (the browser took over the gesture, e.g. a system swipe) is neither a
// click nor a drag release: just reset drag state so we don't open, snap, or dismiss.
private onDragCancel = () => {
this.dragging = false;
this.dragMoved = false;
this.dragPos = null;
};
// Hide the panel for the rest of this page visit. Deliberately NOT persisted: a refresh or
// the next visit brings it back (a light "not now", not a permanent opt-out). Stop probing
// and tear the player down so nothing keeps streaming behind the hidden panel.
private dismiss() {
this.dismissed = true;
this.dragPos = null;
this.mountGen++; // stale player callbacks fail fresh() and become no-ops
if (this.recheckTimer) {
clearTimeout(this.recheckTimer);
this.recheckTimer = null;
}
this.teardownPlayer();
}
private toggleMinimize = () => {
this.minimized = !this.minimized;
localStorage.setItem(MIN_KEY, String(this.minimized));
@@ -330,7 +382,7 @@ export class FeaturedStream extends LitElement {
};
render() {
if (!this.channels.length) return html``;
if (!this.channels.length || this.dismissed) 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
@@ -358,7 +410,7 @@ export class FeaturedStream extends LitElement {
@pointerdown=${this.onDragDown}
@pointermove=${this.onDragMove}
@pointerup=${this.onDragUp}
@pointercancel=${this.onDragUp}
@pointercancel=${this.onDragCancel}
>
<button
type="button"
+19 -1
View File
@@ -1,7 +1,10 @@
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 {
cornerFromCenter,
isOffFrame,
} from "../../../src/client/FeaturedStream";
import { FeaturedStreamSchema } from "../../../src/core/ApiSchemas";
describe("FeaturedStream", () => {
@@ -122,4 +125,19 @@ describe("FeaturedStream", () => {
expect(cornerFromCenter(900, 700, 1000, 800)).toBe("br");
});
});
describe("isOffFrame", () => {
it("is false while the center is inside the viewport", () => {
expect(isOffFrame(500, 400, 1000, 800)).toBe(false);
expect(isOffFrame(0, 0, 1000, 800)).toBe(false);
expect(isOffFrame(1000, 800, 1000, 800)).toBe(false);
});
it("is true once the center passes any edge (flick-to-dismiss)", () => {
expect(isOffFrame(-1, 400, 1000, 800)).toBe(true); // left
expect(isOffFrame(1001, 400, 1000, 800)).toBe(true); // right
expect(isOffFrame(500, -1, 1000, 800)).toBe(true); // top
expect(isOffFrame(500, 801, 1000, 800)).toBe(true); // bottom
});
});
});