@@ -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