Skip to content

Commit df67992

Browse files
committed
chore(infrastructure): e2e ci
1 parent c14ae1f commit df67992

10 files changed

Lines changed: 852 additions & 32 deletions

File tree

app.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,18 @@ const config: ExpoConfig = {
263263
? appConfig.androidGoogleServicesFile
264264
: undefined,
265265
edgeToEdgeEnabled: true,
266+
intentFilters: [
267+
{
268+
action: 'VIEW',
269+
autoVerify: true,
270+
category: ['BROWSABLE', 'DEFAULT'],
271+
data: [
272+
{ scheme: 'https', host: 'www.twitch.tv' },
273+
{ scheme: 'https', host: 'twitch.tv' },
274+
{ scheme: 'https', host: 'm.twitch.tv' },
275+
],
276+
},
277+
],
266278
},
267279
};
268280

docs/TWITCH_EMBED_PARENT.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Twitch embed parent (mature content gate)
2+
3+
The stream player loads the **raw Twitch player** URL (`player.twitch.tv`) with a `parent` query param. The value of `parent` controls which gate Twitch shows for **mature content**:
4+
5+
| `parent` value | Mature stream behaviour |
6+
|---------------------|-------------------------|
7+
| App name (e.g. `foam`, `frosty`) | Twitch shows **“Log in or create an account”** (login-required gate). WebView may redirect away from the player; we cannot auto-accept. |
8+
| **`www.twitch.tv`** | Twitch shows the **anonymous “Continue”** content gate. We stay on the player URL and can auto-click it via injected JS. |
9+
10+
## Current approach (Foam)
11+
12+
- **Default `parent`: `www.twitch.tv`** so anonymous users get the “Continue” gate for mature streams (same as twitch.tv when logged out), and we can auto-accept it.
13+
- We load only the raw URL:
14+
`https://player.twitch.tv/?channel=...&muted=true&parent=www.twitch.tv`
15+
with no custom Referer/Origin headers.
16+
- Content gate is auto-accepted via injected JS: `_asyncQuerySelector('button[data-a-target*="content-classification-gate"]', 10000)` then click (Frosty PR #406 / issue #404 style).
17+
18+
## Why not `parent=foam` or a custom domain?
19+
20+
- With a non–twitch.tv parent, Twitch treats the embed as external and shows the **login-required** gate for mature content. The WebView can redirect to twitch.tv login; `onLoadEnd` / `onPageFinished` then see the redirect URL, not the player URL, so `initVideo()` and the content-gate click **never run**.
21+
- Using `parent=www.twitch.tv` keeps the request “on-site” so we get the anonymous gate and stay on the player, allowing auto-accept to work.
22+
23+
## Overriding the default
24+
25+
You can still pass a different `parent` via the `StreamPlayer` prop if needed (e.g. for testing). For normal use, leave the default.
26+
27+
## References
28+
29+
- Frosty mature content gate fix plan (this repo).
30+
- [Twitch Embed – Video & Clips](https://dev.twitch.tv/docs/embed/video-and-clips/)
31+
- Frosty: [issue #404](https://github.com/tommyxchow/frosty/issues/404), [PR #406](https://github.com/tommyxchow/frosty/pull/406) (auto-accept content warning).

src/components/StreamPlayer/StreamPlayer.tsx

Lines changed: 209 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,8 @@ export interface StreamPlayerProps {
201201
*/
202202
onRefresh?: () => void;
203203
/**
204-
* Parent domain for embed
204+
* Parent domain for Twitch embed. Must be an HTTPS domain you added in the
205+
* Twitch Developer Console (e.g. foam-app.com). We send Referer/Origin so Twitch validates.
205206
* @default 'foam-app.com'
206207
*/
207208
parent?: string;
@@ -276,6 +277,174 @@ type PlayerMessage =
276277
| { type: 'trace'; payload: { step: string; detail?: string } }
277278
| { type: 'error'; payload: { message: string } };
278279

280+
/**
281+
* Build the raw Twitch player URL.
282+
* Use parent=www.twitch.tv so Twitch shows the anonymous "Continue" content gate for
283+
* mature streams instead of redirecting to the login-required gate (which we cannot
284+
* auto-accept). See: Frosty mature content gate fix plan.
285+
*/
286+
function buildRawPlayerUrl(options: {
287+
channel: string;
288+
video?: string;
289+
parent: string;
290+
muted: boolean;
291+
}): string {
292+
const params = new URLSearchParams();
293+
if (options.video) {
294+
params.set('video', options.video);
295+
} else {
296+
params.set('channel', options.channel || 'twitch');
297+
}
298+
params.set('muted', String(options.muted));
299+
params.set('parent', options.parent);
300+
return `https://player.twitch.tv/?${params.toString()}`;
301+
}
302+
303+
/** True if parent looks like an HTTPS domain (e.g. foam-app.com) so we send Referer/Origin. */
304+
function isHttpsDomain(parent: string): boolean {
305+
return parent.includes('.') && !parent.startsWith('http');
306+
}
307+
308+
/** Selectors Twitch may use for the content classification / age gate button. */
309+
const CONTENT_GATE_SELECTORS = [
310+
'button[data-a-target*="content-classification-gate"]',
311+
'[data-a-target*="content-classification-gate"]',
312+
'button[data-a-target*="content-gate"]',
313+
'a[data-a-target*="content-classification-gate"]',
314+
'button[aria-label*="Continue"]',
315+
'button[aria-label*="Watch"]',
316+
'[data-a-target*="player-overlay-content-gate"] button',
317+
'[data-a-target*="player-overlay-content-gate"] a',
318+
];
319+
320+
/**
321+
* Injected script for the raw player: click age gate aggressively, then bridge video and keep playing.
322+
* Frosty fix (PR #406 / issue #404): MutationObserver with 10s timeout to find content warning button and click it.
323+
* - We add _asyncQuerySelector (MutationObserver waiter) and acceptContentWarning() that uses it.
324+
* - Also keep polling loop and onLoadEnd triggers for redundancy.
325+
*/
326+
function getRawPlayerInjectedScript(): string {
327+
const gateSelectorsJson = JSON.stringify(CONTENT_GATE_SELECTORS);
328+
return `
329+
(function() {
330+
function post(type, payload) {
331+
try { window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload || {} })); } catch (e) {}
332+
}
333+
function findGateButton() {
334+
var selectors = ${gateSelectorsJson};
335+
for (var i = 0; i < selectors.length; i++) {
336+
var el = document.querySelector(selectors[i]);
337+
if (el) return el;
338+
}
339+
var all = document.querySelectorAll('button, [role="button"], a[href]');
340+
for (var j = 0; j < all.length; j++) {
341+
var b = all[j];
342+
var t = (b.textContent || '').toLowerCase();
343+
if (/continue|watch\\s*(stream|now)?|i'?m\\s*over|confirm|yes\\s*,?\\s*i\\s*am/.test(t)) return b;
344+
}
345+
return null;
346+
}
347+
function clickGate() {
348+
var btn = findGateButton();
349+
if (btn) { try { btn.click(); } catch (e) {} return true; }
350+
return null;
351+
}
352+
window._asyncQuerySelector = function(selector, timeoutMs) {
353+
var el = document.querySelector(selector);
354+
if (el) return Promise.resolve(el);
355+
return new Promise(function(resolve) {
356+
var observer = new MutationObserver(function() {
357+
var found = document.querySelector(selector);
358+
if (found) { observer.disconnect(); resolve(found); }
359+
});
360+
observer.observe(document.body, { childList: true, subtree: true });
361+
if (timeoutMs) {
362+
setTimeout(function() { observer.disconnect(); resolve(document.querySelector(selector) || null); }, timeoutMs);
363+
}
364+
});
365+
};
366+
function acceptContentWarning() {
367+
var sel = 'button[data-a-target*="content-classification-gate"]';
368+
(window._asyncQuerySelector || function(s, t) { return Promise.resolve(document.querySelector(s)); })(sel, 10000).then(function(btn) {
369+
if (btn) { try { btn.click(); } catch (e) {} }
370+
}).catch(function() {});
371+
}
372+
acceptContentWarning();
373+
function runGateClickLoop() {
374+
if (window._gateLoopRunning) return;
375+
window._gateLoopRunning = true;
376+
var n = 0;
377+
var id = setInterval(function() {
378+
n++;
379+
if (n > 60) { clearInterval(id); window._gateLoopRunning = false; return; }
380+
clickGate();
381+
}, 400);
382+
}
383+
runGateClickLoop();
384+
385+
async function doInit() {
386+
if (window._rawPlayerInitDone) return;
387+
var videoEl = null;
388+
for (var attempts = 0; attempts < 40; attempts++) {
389+
videoEl = document.querySelector('video');
390+
if (videoEl) break;
391+
await new Promise(function(r) { setTimeout(r, 500); });
392+
}
393+
if (!videoEl) return;
394+
if (window._rawPlayerInitDone) return;
395+
window._rawPlayerInitDone = true;
396+
window._videoEl = videoEl;
397+
videoEl.muted = true;
398+
post('ready');
399+
if (!videoEl._listenersAdded) {
400+
videoEl._listenersAdded = true;
401+
videoEl.addEventListener('play', function() { post('play'); });
402+
videoEl.addEventListener('playing', function() {
403+
window._pauseRetries = 0;
404+
videoEl.muted = false;
405+
videoEl.volume = 1;
406+
post('stateUpdate', { isBuffering: false, isPaused: false, isReady: true });
407+
});
408+
videoEl.addEventListener('pause', function() {
409+
post('pause');
410+
if (window._pauseRetries === undefined) window._pauseRetries = 0;
411+
window._pauseRetries++;
412+
if (window._pauseRetries <= 8) {
413+
setTimeout(function() { clickGate(); videoEl.play().catch(function(){}); }, 400);
414+
}
415+
});
416+
videoEl.addEventListener('ended', function() { post('ended'); });
417+
}
418+
window.playerControls = {
419+
play: function() { videoEl.play(); },
420+
pause: function() { videoEl.pause(); },
421+
setMuted: function(m) { videoEl.muted = !!m; },
422+
unmute: function() { videoEl.muted = false; videoEl.volume = 1; },
423+
setVolume: function(v) { videoEl.volume = v; if (v > 0) videoEl.muted = false; },
424+
getCurrentTime: function() { post('currentTime', { time: videoEl.currentTime }); },
425+
getDuration: function() { post('duration', { duration: videoEl.duration }); },
426+
seek: function(t) { videoEl.currentTime = t; },
427+
setChannel: function() {},
428+
setVideo: function() {},
429+
setQuality: function() {},
430+
seekToLive: function() {}
431+
};
432+
function tryPlay() {
433+
videoEl.play().then(function() { post('play'); }).catch(function() {});
434+
}
435+
tryPlay();
436+
[500,1000,2000,3000,5000,8000,12000].forEach(function(ms) { setTimeout(tryPlay, ms); });
437+
if (!videoEl.paused) post('play');
438+
}
439+
doInit();
440+
setTimeout(doInit, 2500);
441+
setTimeout(doInit, 6000);
442+
window.__foamClickGate = function() { acceptContentWarning(); runGateClickLoop(); clickGate(); };
443+
})();
444+
true;
445+
`;
446+
}
447+
279448
/**
280449
* Build HTML that embeds Twitch using the official Embed JavaScript API.
281450
* @see https://dev.twitch.tv/docs/embed/video-and-clips/#interactive-frames-for-live-streams-and-vods
@@ -574,7 +743,7 @@ export const StreamPlayer = forwardRef<StreamPlayerRef, StreamPlayerProps>(
574743
onPlay,
575744
onReady,
576745
onRefresh,
577-
parent = 'foam-app.com',
746+
parent = 'www.twitch.tv',
578747
// eslint-disable-next-line @typescript-eslint/no-unused-vars
579748
streamProxyBaseUrl: _streamProxyBaseUrl,
580749
showOverlayControls = true,
@@ -916,6 +1085,34 @@ export const StreamPlayer = forwardRef<StreamPlayerRef, StreamPlayerProps>(
9161085
[channel, video, parent, autoplay, initialMuted],
9171086
);
9181087

1088+
const rawPlayerUrl = useMemo(
1089+
() =>
1090+
buildRawPlayerUrl({
1091+
channel: channel || 'twitch',
1092+
video,
1093+
parent,
1094+
muted: true,
1095+
}),
1096+
[channel, video, parent],
1097+
);
1098+
1099+
// parent=www.twitch.tv so we get anonymous "Continue" gate for mature content (not login gate).
1100+
const webViewSource = useMemo(() => {
1101+
if (__DEV__) {
1102+
console.warn('[StreamPlayer] loading raw player', {
1103+
url: rawPlayerUrl,
1104+
parent,
1105+
note: 'no custom headers (Frosty approach)',
1106+
});
1107+
}
1108+
return { uri: rawPlayerUrl };
1109+
}, [rawPlayerUrl, parent]);
1110+
1111+
const rawPlayerInjectedScript = useMemo(
1112+
() => getRawPlayerInjectedScript(),
1113+
[],
1114+
);
1115+
9191116
const showControls = useCallback(() => {
9201117
controlsVisibleRef.current = true;
9211118
setControlsVisible(true);
@@ -1046,16 +1243,21 @@ export const StreamPlayer = forwardRef<StreamPlayerRef, StreamPlayerProps>(
10461243
javaScriptEnabled
10471244
mediaPlaybackRequiresUserAction={false}
10481245
originWhitelist={['*']}
1049-
source={{
1050-
html: embedHtml,
1051-
baseUrl: 'https://embed.twitch.tv/',
1052-
}}
1246+
source={webViewSource}
1247+
injectedJavaScript={rawPlayerInjectedScript}
10531248
style={[styles.webView, hasContentGate && styles.webViewScrollable]}
10541249
onContentProcessDidTerminate={() => webViewRef.current?.reload()}
10551250
onError={handleWebViewError}
10561251
onHttpError={handleWebViewHttpError}
10571252
onLoadEnd={() => {
1058-
console.warn('[StreamPlayer:WebView] onLoadEnd - HTML load finished');
1253+
if (__DEV__) console.warn('[StreamPlayer] onLoadEnd, triggering gate click');
1254+
const runGateClick = () =>
1255+
webViewRef.current?.injectJavaScript(
1256+
'typeof window.__foamClickGate==="function"&&window.__foamClickGate();true;',
1257+
);
1258+
setTimeout(runGateClick, 800);
1259+
setTimeout(runGateClick, 2000);
1260+
setTimeout(runGateClick, 5000);
10591261
}}
10601262
onLoadStart={() => {
10611263
console.warn('[StreamPlayer:WebView] onLoadStart', {

0 commit comments

Comments
 (0)