feat(mobile): Chromecast (0.3.0, runtime 1.3)

Zgloszenie de93ba42 ("Can't stream to Chrome cast"). Cast to natywny SDK, wiec NIE da
sie go wyslac przez OTA: to nowe APK, ktore ludzie musza pobrac sami. Klucz podpisujacy
ten sam, wiec ALLOWED_APP_SIG_HASH bez zmian i aktualizacja wchodzi na wierzch starej.

Argument, ktory przewazyl: od czerwca release ma wlaczone FLAG_SECURE, wiec zwykle
mirrorowanie ekranu na telewizor jest zablokowane. Cast jest jedyna droga na duzy ekran.

Zasieg: 77% udanych odtworzen konczy sie bezposrednim adresem, plus czesc hosterow
filmowych oddajacych gotowy plik. Okolo 13% (porndoe, mypornerleak, superporn) idzie
przez WebView i castowac sie NIE DA, tam przycisku nie ma.

Wpiete recznie, bez `expo prebuild`, bo katalog android/ jest w repo i ma reczne zmiany,
ktore prebuild by przepisal. Cztery edycje, dokladnie to co robi config plugin:
manifest (OPTIONS_PROVIDER + expanded controller), MainActivity, castFrameworkVersion
w rootProject.ext oraz JAWNA zaleznosc play-services-cast-framework w app/build.gradle.
Ta ostatnia jest konieczna, bo biblioteka deklaruje ja jako `implementation`, co nie
jest przechodnie na sciezke kompilacji, i bez niej build pada na compileReleaseKotlin.

Identyfikator odbiornika CELOWO zaden: bez wpisu biblioteka bierze domyslny odbiornik
Google. Rejestracja wlasnego wiazalaby konto Google z Goonem, a przy tresci dla
doroslych latwo o cofniecie.

Urzadzenia bez Google Play Services zabezpieczone w DWOCH miejscach, bo mamy uzytkownika
na LineageOS (GOON-3B) i sideload na zegarku (GOON-51): `getSharedInstance` w try/catch
po stronie natywnej, a po stronie JS cala obsluga w osobnym `CastControl`, montowanym
dopiero gdy `useCastAvailable` potwierdzi Play Services. To nie jest ostroznosc na
wyrost: hooki biblioteki synchronicznie subskrybuja natywne zdarzenia i bez GMS wywracaja
ekran, a hooka nie da sie wywolac warunkowo.

Chromecast pobiera plik SAM i nie wysyla naszych naglowkow, wiec zrodla wymagajace
Referera ida przez /proxy (dokłada go po stronie serwera), a reszta bezposrednio, zeby
nie obciazac VPS. Do tego proxy przestaje przekazywac CORS hostera: mixdrop oddawal
`Access-Control-Allow-Origin: https://mixdrop.my`, co odbiornik odrzucal przy HLS.

Przy okazji GOON-3B: doszedl brakujacy wpis `com.google.android.gms.version`, ktory
wywracal apke na LineageOS, gdy Chromium probowal podpiac skaner kodow.

Zweryfikowane na emulatorze na podpisanym APK: certyfikat identyczny ze starym
(3294aba5...), instalacja NA WIERZCH 0.2.2 udana, wersja 13/0.3.0, runtime 1.3 w
manifescie, aplikacja wstaje bez crasha, ExoPlayer gra (MediaCodec dla video), a SDK
Casta startuje poprawnie: dynamite sie laduje i `MediaRouterProxy` czyta NASZ CastOptions.
Logika wyboru adresu przetestowana osobno na osmiu przypadkach.

CZEGO NIE ZWERYFIKOWALEM: realnego rzutu na telewizor. W sieci emulatora nie ma zadnego
Chromecasta, wiec `loadMedia` i odtwarzanie po stronie odbiornika sa niesprawdzone.

Build wymaga SENTRY_DISABLE_AUTO_UPLOAD=true, bo sentry.properties czeka na
SENTRY_ORG/SENTRY_PROJECT/SENTRY_AUTH_TOKEN ze srodowiska (Infisical, workspace
goon-app). Skutek: slady JS z 0.3.0 beda w Sentry nieczytelne do czasu przebudowy z tymi
zmiennymi.
This commit is contained in:
goon-foss 2026-08-24 14:27:41 +02:00
parent dad4bbf0ab
commit bdac60f022
13 changed files with 326 additions and 16 deletions

View file

@ -148,6 +148,36 @@ HOP_BY_HOP = {
"content-length", "content-length",
} }
# Nagłówki CORS hostera są bezużyteczne dalej i wręcz szkodliwe: mixdrop oddaje
# `Access-Control-Allow-Origin: https://mixdrop.my`, więc odbiornik Chromecasta (który
# ma zupełnie inny origin) odrzuciłby manifest. Wycinamy je i podstawiamy własne.
_UPSTREAM_CORS_HEADERS = {
"access-control-allow-origin",
"access-control-allow-methods",
"access-control-allow-headers",
"access-control-allow-credentials",
"access-control-expose-headers",
}
def _with_cast_cors(headers: dict[str, str]) -> dict[str, str]:
"""Pozwól odtwarzać ten strumień z dowolnego origin.
Po co: Chromecast pobiera plik SAM, ze swojego adresu, a przy HLS robi to przez
Media Source Extensions, które wymagają zgody CORS. Bez tego telewizor dostaje
manifest i go wyrzuca. Progresywne MP4 leci przez zwykły element `video` i o CORS
nie pyta, ale ustawiamy jednakowo, żeby nie zależeć od tego, co akurat gra.
Bezpieczne, bo dostęp do treści i tak reguluje podpisany token w ścieżce, a nie
origin pytającego.
"""
out = {k: v for k, v in headers.items() if k.lower() not in _UPSTREAM_CORS_HEADERS}
out["Access-Control-Allow-Origin"] = "*"
out["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"
out["Access-Control-Allow-Headers"] = "Range"
out["Access-Control-Expose-Headers"] = "Content-Length, Content-Range, Accept-Ranges"
return out
def _secret() -> bytes: def _secret() -> bytes:
s = os.environ.get("STREAM_PROXY_SECRET") or os.environ.get("API_KEYS", "") s = os.environ.get("STREAM_PROXY_SECRET") or os.environ.get("API_KEYS", "")
@ -618,9 +648,9 @@ async def _curl_cffi_stream(
await session.close() await session.close()
return _upstream_error_response(upstream.status_code, dict(upstream.headers), target) return _upstream_error_response(upstream.status_code, dict(upstream.headers), target)
out_headers = { out_headers = _with_cast_cors(
k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP {k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP}
} )
async def streamer(): async def streamer():
bytes_out = 0 bytes_out = 0
@ -711,7 +741,7 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
return Response( return Response(
content=rewritten, content=rewritten,
media_type="application/vnd.apple.mpegurl", media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-store"}, headers=_with_cast_cors({"Cache-Control": "no-store"}),
) )
@ -834,9 +864,9 @@ async def proxy_stream(
headers={"Cache-Control": "no-store"}, headers={"Cache-Control": "no-store"},
) )
out_headers = { out_headers = _with_cast_cors(
k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP {k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP}
} )
async def streamer(): async def streamer():
bytes_out = 0 bytes_out = 0

View file

@ -121,7 +121,7 @@ def version() -> dict[str, str | None]:
# mobile sklei z baseUrl. # mobile sklei z baseUrl.
public_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/") public_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
apk_url = f"{public_url}/static/app-release.apk" if public_url else "/static/app-release.apk" apk_url = f"{public_url}/static/app-release.apk" if public_url else "/static/app-release.apk"
return {"version": "0.2.2", "apk_url": apk_url} return {"version": "0.3.0", "apk_url": apk_url}
@app.get("/readyz") @app.get("/readyz")

View file

@ -93,8 +93,8 @@ android {
applicationId 'com.goon.mobile' applicationId 'com.goon.mobile'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 12 versionCode 13
versionName "0.2.2" versionName "0.3.0"
} }
signingConfigs { signingConfigs {
debug { debug {
@ -167,6 +167,13 @@ dependencies {
// The version of react-native is set by the React Native Gradle Plugin // The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android") implementation("com.facebook.react:react-android")
// Chromecast. react-native-google-cast deklaruje to jako `implementation`, a taka
// zależność NIE jest przechodnia na ścieżkę kompilacji, więc bez tego wpisu
// `MainActivity.kt` nie widzi typu `CastContext` zwracanego przez
// `RNGCCastContext.getSharedInstance`. Wersja z `castFrameworkVersion` w
// android/build.gradle, żeby moduł i aplikacja szły po tej samej.
implementation("com.google.android.gms:play-services-cast-framework:${rootProject.ext.castFrameworkVersion}")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";

View file

@ -29,7 +29,16 @@
jest w repo (bare workflow), wiec zmiana app.json sama tego NIE przepisuje, robi to jest w repo (bare workflow), wiec zmiana app.json sama tego NIE przepisuje, robi to
dopiero prebuild. Rozjazd = APK siedzi na cudzym kanale OTA: przy 0.2.2 z wpisem 1.1 dopiero prebuild. Rozjazd = APK siedzi na cudzym kanale OTA: przy 0.2.2 z wpisem 1.1
nowa aplikacja dostalaby baner migracji i kazala migrowac jeszcze raz, w kolko. --> nowa aplikacja dostalaby baner migracji i kazala migrowac jeszcze raz, w kolko. -->
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="1.2"/> <meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="1.3"/>
<!-- Chromecast. `GoogleCastOptionsProvider` bez wpisu RECEIVER_APPLICATION_ID sam
wybiera domyslny odbiornik Google (`DEFAULT_MEDIA_RECEIVER_APPLICATION_ID`), wiec
CELOWO nie rejestrujemy wlasnego: rejestracja w konsoli Google wiazalaby konto
Google z Goonem i przy tresci dla doroslych latwo o cofniecie identyfikatora. -->
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:value="com.reactnative.googlecast.GoogleCastOptionsProvider"/>
<!-- Wpis wymagany przez Google Play Services. Jego brak wywracal apke na LineageOS,
gdy Chromium probowal podpiac skaner kodow (GOON-3B, 3 crashe od lipca). -->
<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/>
<activity android:name="com.reactnative.googlecast.RNGCExpandedControllerActivity"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait"> <activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>

View file

@ -2,12 +2,15 @@ package com.goon.mobile
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import com.facebook.react.ReactActivity import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate import com.facebook.react.defaults.DefaultReactActivityDelegate
import com.reactnative.googlecast.api.RNGCCastContext
import expo.modules.ReactActivityDelegateWrapper import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() { class MainActivity : ReactActivity() {
@ -17,6 +20,16 @@ class MainActivity : ReactActivity() {
// This is required for expo-splash-screen. // This is required for expo-splash-screen.
setTheme(R.style.AppTheme); setTheme(R.style.AppTheme);
super.onCreate(null) super.onCreate(null)
// Cast wymaga inicjalizacji z Activity. Owinięte w try, bo `CastContext` sięga po
// Google Play Services, a mamy użytkowników na systemach bez GMS (LineageOS, zob.
// GOON-3B) i sideload na zegarku. Tam ma po prostu nie być castowania, a nie crash
// przy starcie. Sama biblioteka zgłasza wtedy brak urządzeń i przycisk się nie pokaże.
try {
RNGCCastContext.getSharedInstance(this)
} catch (e: Throwable) {
Log.w("Goon", "Cast niedostepny na tym urzadzeniu: " + e.javaClass.simpleName)
}
} }
/** /**

View file

@ -9,6 +9,11 @@ buildscript {
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25' kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
ndkVersion = "26.1.10909125" ndkVersion = "26.1.10909125"
// Chromecast (react-native-google-cast). Biblioteka czyta to przez `safeExtGet`
// z rootProject.ext, a bez wpisu bierze "+", czyli wersje z dnia buildu. Pinujemy,
// zeby dwa buildy z tego samego commita dawaly ten sam APK.
castFrameworkVersion = "21.5.0"
} }
repositories { repositories {
google() google()

View file

@ -2,11 +2,11 @@
"expo": { "expo": {
"name": "goon", "name": "goon",
"slug": "goon", "slug": "goon",
"version": "0.2.2", "version": "0.3.0",
"orientation": "portrait", "orientation": "portrait",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": false, "newArchEnabled": false,
"runtimeVersion": "1.2", "runtimeVersion": "1.3",
"updates": { "updates": {
"enabled": true, "enabled": true,
"url": "https://api.goon-foss.org/expo-updates/manifest", "url": "https://api.goon-foss.org/expo-updates/manifest",

View file

@ -32,6 +32,7 @@
"react": "18.3.1", "react": "18.3.1",
"react-native": "0.76.9", "react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.0", "react-native-gesture-handler": "~2.20.0",
"react-native-google-cast": "^4.9.1",
"react-native-qrcode-svg": "^6.3.21", "react-native-qrcode-svg": "^6.3.21",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",
@ -10158,6 +10159,16 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-google-cast": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/react-native-google-cast/-/react-native-google-cast-4.9.1.tgz",
"integrity": "sha512-/HvIKAaWHtG6aTNCxrNrqA2ftWGkfH0M/2iN+28pdGUXpKmueb33mgL1m8D4zzwEODQMcmpfoCsym1IwDvugBQ==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-qrcode-svg": { "node_modules/react-native-qrcode-svg": {
"version": "6.3.21", "version": "6.3.21",
"resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.21.tgz", "resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.21.tgz",

View file

@ -34,6 +34,7 @@
"react": "18.3.1", "react": "18.3.1",
"react-native": "0.76.9", "react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.0", "react-native-gesture-handler": "~2.20.0",
"react-native-google-cast": "^4.9.1",
"react-native-qrcode-svg": "^6.3.21", "react-native-qrcode-svg": "^6.3.21",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",

View file

@ -6,6 +6,8 @@
* Przy każdym publish OTA: DOPISZ nowy wpis NA GÓRZE (newest-first), z nowym `id`. * Przy każdym publish OTA: DOPISZ nowy wpis NA GÓRZE (newest-first), z nowym `id`.
* Treść user-facing (bez wewnętrznego żargonu), to widzi użytkownik. * Treść user-facing (bez wewnętrznego żargonu), to widzi użytkownik.
*/ */
import { APP_VERSION, isNewerVersion } from './lib/appVersion';
export type ChangelogEntry = { export type ChangelogEntry = {
/** Stabilny, rosnący identyfikator (np. data + seq). Newest-first w CHANGELOG. */ /** Stabilny, rosnący identyfikator (np. data + seq). Newest-first w CHANGELOG. */
id: string; id: string;
@ -13,9 +15,33 @@ export type ChangelogEntry = {
date: string; date: string;
/** Punkty zmian, krótkie, user-facing. */ /** Punkty zmian, krótkie, user-facing. */
items: string[]; items: string[];
/**
* Pokaż ten wpis tylko użytkownikom PONIŻEJ tej wersji aplikacji.
*
* Po co: przy zmianach wymagających nowego APK (natywnych) ten sam bundle leci na
* kanał starej i nowej wersji. Bez tego użytkownik, który już zainstalował 0.3.0,
* dostawał komunikat „pobierz 0.3.0". Wpisy bez tego pola widzą wszyscy.
*/
onlyBelowVersion?: string;
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-08-24b',
date: 'August 2026',
items: [
'Chromecast is here, but it needs version 0.3.0, which you have to download and install yourself. Casting needs a part of the app that cannot be delivered as a silent update. Get it from goon-foss.org; it installs over the version you already have and keeps your settings.',
],
onlyBelowVersion: '0.3.0',
},
{
id: '2026-08-24a',
date: 'August 2026',
items: [
'Movie hosts that showed a black screen or a spinner forever are now recognised as gone and drop off the list, so the ones that actually play come first. The host behind them lost its files: its own player answers "Video is not ready yet". Only one movie in the whole catalogue relied on it alone.',
'Cleaned up performers who were credited on scenes they are not in. A scene listing "Julia Ann" was also being credited to "Julia B." because only the first name was being checked. 85,379 wrong credits removed.',
],
},
{ {
id: '2026-08-23a', id: '2026-08-23a',
date: 'August 2026', date: 'August 2026',
@ -552,8 +578,16 @@ export const NEWEST_CHANGELOG_ID = CHANGELOG.length ? CHANGELOG[0].id : null;
* (nie zrzucamy całej historii). lastSeen == najnowszy → pusto (nic do pokazania). */ * (nie zrzucamy całej historii). lastSeen == najnowszy → pusto (nic do pokazania). */
export function unseenEntries(lastSeenId: string | null): ChangelogEntry[] { export function unseenEntries(lastSeenId: string | null): ChangelogEntry[] {
if (!CHANGELOG.length) return []; if (!CHANGELOG.length) return [];
if (!lastSeenId) return [CHANGELOG[0]]; const visible = CHANGELOG.filter(
(e) => !e.onlyBelowVersion || isNewerVersion(e.onlyBelowVersion, APP_VERSION),
);
if (!visible.length) return [];
if (!lastSeenId) return [visible[0]];
// Indeks liczymy na PEŁNEJ liście, bo `lastSeenId` mógł wskazywać wpis odfiltrowany
// dla tej wersji; inaczej po aktualizacji APK marker byłby nieznany i pokazalibyśmy
// najnowszy wpis drugi raz.
const idx = CHANGELOG.findIndex((e) => e.id === lastSeenId); const idx = CHANGELOG.findIndex((e) => e.id === lastSeenId);
if (idx === -1) return [CHANGELOG[0]]; // nieznany marker → pokaż tylko najnowszy if (idx === -1) return [visible[0]]; // nieznany marker → pokaż tylko najnowszy
return CHANGELOG.slice(0, idx); const seenIds = new Set(CHANGELOG.slice(idx).map((e) => e.id));
return visible.filter((e) => !seenIds.has(e.id));
} }

View file

@ -0,0 +1,84 @@
/**
* Przycisk Chromecasta i wysyłka materiału na telewizor.
*
* Dlaczego to osobny komponent, a nie kilka linijek w `PlayerScreen`: hooki biblioteki
* (`useRemoteMediaClient` → `useCastSession`) **synchronicznie subskrybują natywne
* zdarzenia** w `useEffect`. Na urządzeniu bez Google Play Services natywnego modułu nie
* ma i taka subskrypcja wywraca cały ekran. Hooka nie da się wywołać warunkowo, więc
* jedynym czystym wyjściem jest zamontowanie tego komponentu dopiero wtedy, gdy wiemy,
* że Cast działa. To nie jest teoria: mamy użytkownika na LineageOS (GOON-3B) i sideload
* na zegarku (GOON-51), oba bez pełnego GMS.
*
* Sprawdzenie dostępności siedzi w `useCastAvailable` i jest asynchroniczne, więc samo
* w sobie niczego nie subskrybuje i jest bezpieczne wszędzie.
*/
import React from 'react';
import { Alert } from 'react-native';
import { CastButton, CastContext, useRemoteMediaClient } from 'react-native-google-cast';
import type { CastTarget } from '../lib/cast';
import { theme } from '../theme';
/** Czy na tym urządzeniu w ogóle da się castować. Null = jeszcze nie wiadomo. */
export function useCastAvailable(): boolean {
const [available, setAvailable] = React.useState(false);
React.useEffect(() => {
let alive = true;
(async () => {
try {
const state = await CastContext.getPlayServicesState();
// `success` to jedyny stan, w którym SDK jest gotowe. Reszta (brak usług,
// wersja do aktualizacji, wyłączone) znaczy dla nas tyle samo: bez castowania.
if (alive) setAvailable(state === 'success');
} catch {
// Natywnego modułu nie ma albo rzucił. Zostajemy przy braku castowania.
if (alive) setAvailable(false);
}
})();
return () => {
alive = false;
};
}, []);
return available;
}
type Props = {
target: CastTarget;
title?: string;
/** Pozycja w sekundach, od której ma zacząć telewizor. */
startTimeSec: number;
/** Wołane po udanym przejęciu odtwarzania, żeby telefon przestał pobierać. */
onCastStarted: () => void;
};
export function CastControl({ target, title, startTimeSec, onCastStarted }: Props) {
const client = useRemoteMediaClient();
const sentRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!client) return;
// Jedno wysłanie na adres. Bez tej blokady każdy re-render ładowałby materiał od
// nowa i telewizor wracałby do początku.
if (sentRef.current === target.contentUrl) return;
sentRef.current = target.contentUrl;
client
.loadMedia({
autoplay: true,
startTime: Math.max(0, Math.floor(startTimeSec)),
mediaInfo: {
contentUrl: target.contentUrl,
contentType: target.contentType,
metadata: { type: 'movie', title: title || 'Goon' },
},
})
.then(onCastStarted)
.catch((e: unknown) => {
sentRef.current = null;
Alert.alert('Cast failed', e instanceof Error ? e.message : 'unknown error');
});
}, [client, target, title, startTimeSec, onCastStarted]);
// Sam przycisk chowa się, gdy w sieci nie widać żadnego urządzenia, więc u kogoś bez
// Chromecasta pasek playera wygląda dokładnie jak dotąd.
return <CastButton style={{ width: 28, height: 28, marginHorizontal: 4 }} tintColor={theme.fg} />;
}

81
mobile/src/lib/cast.ts Normal file
View file

@ -0,0 +1,81 @@
/**
* Wybór adresu, który da się rzucić na Chromecasta, i jego typu MIME.
*
* Dlaczego to nie jest po prostu `url` z playera: **Chromecast pobiera plik SAM**, ze
* swojego adresu w sieci lokalnej, i nie wysyła przy tym naszych nagłówków. Domyślny
* odbiornik Google nie pozwala dołożyć Referera do żądania o media. Wszystko, co u nas
* gra tylko dzięki nagłówkowi `Referer` (a takich CDN-ów jest sporo, patrz `paramHeaders`
* w `PlayerScreen`), poleciałoby na telewizorze 403.
*
* Stąd zasada: gdy odtwarzanie wymaga własnych nagłówków, castujemy adres przez nasze
* proxy, bo ono dokłada Referera po stronie serwera. Gdy nagłówki nie są potrzebne,
* castujemy adres bezpośredni i ruch w ogóle nie dotyka naszego VPS.
*
* Token IP-bound nie jest tu problemem: Chromecast siedzi w tej samej sieci co telefon,
* więc wychodzi na świat z tego samego adresu publicznego.
*/
/** Nagłówki, które zmieniają wynik pobrania. `User-Agent` sam z siebie nie wystarcza. */
const HEADERS_THAT_MATTER = ['referer', 'cookie', 'authorization'];
export type CastTarget = {
contentUrl: string;
contentType: string;
/** true = poszło przez nasze proxy, czyli transfer obciąża VPS. Do telemetrii i UI. */
viaProxy: boolean;
};
function needsOwnHeaders(headers?: Record<string, string> | null): boolean {
if (!headers) return false;
return Object.keys(headers).some((k) => HEADERS_THAT_MATTER.includes(k.toLowerCase()));
}
/**
* HLS musi zostać rozpoznany jako HLS, bo odbiornik puszcza go przez MSE, a nie przez
* zwykły `<video src>`. Rozpoznajemy po ścieżce, nie po całym adresie: nasze proxy
* pakuje oryginalny adres do tokena w base64 i słowo „m3u8" potrafi tam wypłynąć
* przypadkiem (ten sam błąd zjadł nas już przy `expo-video`, patrz `/proxy/hls`).
*/
export function guessContentType(rawUrl: string, hintedType?: string | null): string {
if (hintedType === 'm3u8') return 'application/x-mpegURL';
if (hintedType === 'mp4') return 'video/mp4';
let path = rawUrl;
try {
path = new URL(rawUrl).pathname;
} catch {
// Adres względny albo śmieć, zostajemy przy całości.
}
return /\.m3u8(\?|$)/i.test(path) ? 'application/x-mpegURL' : 'video/mp4';
}
/**
* Zwraca adres do wysłania na telewizor albo `null`, gdy tej sceny nie da się rzucić.
*
* `null` dostajemy dla źródeł otwieranych w WebView (hostery typu porndoe czy
* mypornerleak, około 13% odtworzeń). Tam nie mamy adresu pliku, tylko stronę z
* playerem, a Chromecast nie umie wyświetlić strony. Przycisk castowania ma być wtedy
* schowany, a nie udawać, że zadziała.
*/
export function pickCastTarget(args: {
url: string;
mode?: 'video' | 'webview';
headers?: Record<string, string> | null;
fallbackProxyUrl?: string | null;
hintedType?: string | null;
}): CastTarget | null {
const { url, mode, headers, fallbackProxyUrl, hintedType } = args;
if (!url || mode === 'webview') return null;
if (needsOwnHeaders(headers)) {
// Bez proxy telewizor dostanie 403. Gdy proxy nie ma, wolimy nie oferować
// castowania niż wysłać adres, który na pewno nie zagra.
if (!fallbackProxyUrl) return null;
return {
contentUrl: fallbackProxyUrl,
contentType: guessContentType(fallbackProxyUrl, hintedType),
viaProxy: true,
};
}
return { contentUrl: url, contentType: guessContentType(url, hintedType), viaProxy: false };
}

View file

@ -7,6 +7,8 @@ import * as Haptics from 'expo-haptics';
import * as ScreenOrientation from 'expo-screen-orientation'; import * as ScreenOrientation from 'expo-screen-orientation';
import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video'; import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import { CastControl, useCastAvailable } from '../components/CastControl';
import { pickCastTarget } from '../lib/cast';
import { setLastPlayback } from '../lib/lastPlayback'; import { setLastPlayback } from '../lib/lastPlayback';
import { allowScreenOff, keepScreenOn } from '../lib/keepAwake'; import { allowScreenOff, keepScreenOn } from '../lib/keepAwake';
import { usePreferences } from '../PreferencesContext'; import { usePreferences } from '../PreferencesContext';
@ -235,6 +237,31 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
p.muted = true; p.muted = true;
p.play(); p.play();
}); });
// Chromecast. `pickCastTarget` oddaje null dla źródeł bez adresu pliku (WebView),
// wtedy przycisku nie ma w ogóle. Samą obsługę montujemy dopiero, gdy `useCastAvailable`
// potwierdzi Play Services: jej hooki subskrybują natywne zdarzenia i bez GMS wywracają
// ekran (zob. komentarz w `CastControl`).
const castTarget = React.useMemo(
() =>
pickCastTarget({
url,
mode: params.mode,
headers: paramHeaders,
fallbackProxyUrl,
}),
[url, params.mode, paramHeaders, fallbackProxyUrl],
);
const castAvailable = useCastAvailable();
const onCastStarted = React.useCallback(() => {
// Telefon przestaje pobierać, gdy gra telewizor. Inaczej lecą dwa strumienie naraz,
// a przy źródłach z limitem połączeń drugi po prostu pada.
try {
player.pause();
} catch {
// Player mógł już zostać zwolniony przy wyjściu z ekranu.
}
}, [player]);
const [muted, setMuted] = React.useState(true); const [muted, setMuted] = React.useState(true);
const toggleMute = React.useCallback(() => { const toggleMute = React.useCallback(() => {
const next = !player.muted; const next = !player.muted;
@ -1018,6 +1045,14 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
<Text style={styles.iconText}>⚙</Text> <Text style={styles.iconText}>⚙</Text>
</Pressable> </Pressable>
) : null} ) : null}
{castTarget && castAvailable ? (
<CastControl
target={castTarget}
title={title}
startTimeSec={player.currentTime || 0}
onCastStarted={onCastStarted}
/>
) : null}
<Pressable onPress={copyLink} hitSlop={16} style={styles.iconBtn}> <Pressable onPress={copyLink} hitSlop={16} style={styles.iconBtn}>
<Text style={styles.iconText}>🔗</Text> <Text style={styles.iconText}>🔗</Text>
</Pressable> </Pressable>