Skip to content

Commit d175719

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

3 files changed

Lines changed: 113 additions & 494 deletions

File tree

docs/TWITCH_EMBED_PARENT.md

Lines changed: 0 additions & 31 deletions
This file was deleted.

src/components/StreamPlayer/StreamPlayer.tsx

Lines changed: 36 additions & 214 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)