@@ -277,174 +277,6 @@ type PlayerMessage =
277277 | { type : 'trace' ; payload : { step : string ; detail ?: string } }
278278 | { type : 'error' ; payload : { message : string } } ;
279279
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-
448280/**
449281 * Build HTML that embeds Twitch using the official Embed JavaScript API.
450282 * @see https://dev.twitch.tv/docs/embed/video-and-clips/#interactive-frames-for-live-streams-and-vods
@@ -1071,47 +903,28 @@ export const StreamPlayer = forwardRef<StreamPlayerRef, StreamPlayerProps>(
1071903 [ onEnded , onError , onOffline , onOnline , onPause , onPlay , onReady ] ,
1072904 ) ;
1073905
1074- const embedHtml = useMemo (
1075- ( ) =>
1076- buildTwitchEmbedHtml ( {
1077- channel : channel || 'twitch' ,
1078- video,
1079- parent,
1080- autoplay,
1081- muted : initialMuted ,
1082- width : '100%' ,
1083- height : '100%' ,
1084- } ) ,
1085- [ channel , video , parent , autoplay , initialMuted ] ,
1086- ) ;
1087-
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).
906+ // Use official Twitch.Player embed (Video and Clips API) so the player is video-only,
907+ // fills the container (100% x 100%), and has no chat or black bars.
908+ // @see https://dev.twitch.tv/docs/embed/video-and-clips/#interactive-frames-for-live-streams-and-vods
1100909 const webViewSource = useMemo ( ( ) => {
910+ const html = buildTwitchEmbedHtml ( {
911+ channel : channel || 'twitch' ,
912+ video,
913+ parent,
914+ autoplay,
915+ muted : initialMuted ,
916+ width : '100%' ,
917+ height : '100%' ,
918+ } ) ;
919+ const baseUrl = parent . startsWith ( 'http' ) ? parent : `https://${ parent } /` ;
1101920 if ( __DEV__ ) {
1102- console . warn ( '[StreamPlayer] loading raw player' , {
1103- url : rawPlayerUrl ,
921+ console . warn ( '[StreamPlayer] loading Twitch.Player embed' , {
1104922 parent,
1105- note : 'no custom headers (Frosty approach)' ,
923+ baseUrl ,
1106924 } ) ;
1107925 }
1108- return { uri : rawPlayerUrl } ;
1109- } , [ rawPlayerUrl , parent ] ) ;
1110-
1111- const rawPlayerInjectedScript = useMemo (
1112- ( ) => getRawPlayerInjectedScript ( ) ,
1113- [ ] ,
1114- ) ;
926+ return { html, baseUrl } ;
927+ } , [ channel , video , parent , autoplay , initialMuted ] ) ;
1115928
1116929 const showControls = useCallback ( ( ) => {
1117930 controlsVisibleRef . current = true ;
@@ -1238,26 +1051,18 @@ export const StreamPlayer = forwardRef<StreamPlayerRef, StreamPlayerProps>(
12381051 >
12391052 < WebView
12401053 ref = { webViewRef }
1241- allowsFullscreenVideo
1054+ allowsFullscreenVideo = { false }
12421055 allowsInlineMediaPlayback
12431056 javaScriptEnabled
12441057 mediaPlaybackRequiresUserAction = { false }
12451058 originWhitelist = { [ '*' ] }
12461059 source = { webViewSource }
1247- injectedJavaScript = { rawPlayerInjectedScript }
12481060 style = { [ styles . webView , hasContentGate && styles . webViewScrollable ] }
12491061 onContentProcessDidTerminate = { ( ) => webViewRef . current ?. reload ( ) }
12501062 onError = { handleWebViewError }
12511063 onHttpError = { handleWebViewHttpError }
12521064 onLoadEnd = { ( ) => {
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 ) ;
1065+ if ( __DEV__ ) console . warn ( '[StreamPlayer] onLoadEnd' ) ;
12611066 } }
12621067 onLoadStart = { ( ) => {
12631068 console . warn ( '[StreamPlayer:WebView] onLoadStart' , {
@@ -1269,6 +1074,16 @@ export const StreamPlayer = forwardRef<StreamPlayerRef, StreamPlayerProps>(
12691074 onRenderProcessGone = { ( ) => webViewRef . current ?. reload ( ) }
12701075 />
12711076
1077+ { showOverlayControls && ! hasContentGate && (
1078+ < PressableArea
1079+ onPress = { toggleControlsInternal }
1080+ style = { styles . touchBlockOverlay }
1081+ pointerEvents = "auto"
1082+ accessibilityLabel = "Show player controls"
1083+ accessibilityRole = "button"
1084+ />
1085+ ) }
1086+
12721087 { __DEV__ && lastHttpError && (
12731088 < View style = { styles . debugErrorOverlay } >
12741089 < Text color = "red" weight = "semibold" >
@@ -1379,6 +1194,13 @@ const styles = StyleSheet.create((theme, rt) => ({
13791194 overflow : 'hidden' ,
13801195 position : 'relative' ,
13811196 } ,
1197+ touchBlockOverlay : {
1198+ position : 'absolute' ,
1199+ top : 0 ,
1200+ left : 0 ,
1201+ right : 0 ,
1202+ bottom : 0 ,
1203+ } ,
13821204 containerScrollable : {
13831205 overflow : 'visible' ,
13841206 } ,
0 commit comments