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:
parent
dad4bbf0ab
commit
bdac60f022
13 changed files with 326 additions and 16 deletions
|
|
@ -148,6 +148,36 @@ HOP_BY_HOP = {
|
|||
"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:
|
||||
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()
|
||||
return _upstream_error_response(upstream.status_code, dict(upstream.headers), target)
|
||||
|
||||
out_headers = {
|
||||
k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP
|
||||
}
|
||||
out_headers = _with_cast_cors(
|
||||
{k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP}
|
||||
)
|
||||
|
||||
async def streamer():
|
||||
bytes_out = 0
|
||||
|
|
@ -711,7 +741,7 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
|
|||
return Response(
|
||||
content=rewritten,
|
||||
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"},
|
||||
)
|
||||
|
||||
out_headers = {
|
||||
k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP
|
||||
}
|
||||
out_headers = _with_cast_cors(
|
||||
{k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP}
|
||||
)
|
||||
|
||||
async def streamer():
|
||||
bytes_out = 0
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ def version() -> dict[str, str | None]:
|
|||
# mobile sklei z baseUrl.
|
||||
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"
|
||||
return {"version": "0.2.2", "apk_url": apk_url}
|
||||
return {"version": "0.3.0", "apk_url": apk_url}
|
||||
|
||||
|
||||
@app.get("/readyz")
|
||||
|
|
|
|||
|
|
@ -93,8 +93,8 @@ android {
|
|||
applicationId 'com.goon.mobile'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 12
|
||||
versionName "0.2.2"
|
||||
versionCode 13
|
||||
versionName "0.3.0"
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
|
|
@ -167,6 +167,13 @@ dependencies {
|
|||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
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 isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
|
|
|||
|
|
@ -29,7 +29,16 @@
|
|||
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
|
||||
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">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@ package com.goon.mobile
|
|||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
|
||||
import com.reactnative.googlecast.api.RNGCCastContext
|
||||
|
||||
import expo.modules.ReactActivityDelegateWrapper
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
|
|
@ -17,6 +20,16 @@ class MainActivity : ReactActivity() {
|
|||
// This is required for expo-splash-screen.
|
||||
setTheme(R.style.AppTheme);
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ buildscript {
|
|||
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||
|
||||
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 {
|
||||
google()
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
"expo": {
|
||||
"name": "goon",
|
||||
"slug": "goon",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": false,
|
||||
"runtimeVersion": "1.2",
|
||||
"runtimeVersion": "1.3",
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
"url": "https://api.goon-foss.org/expo-updates/manifest",
|
||||
|
|
|
|||
11
mobile/package-lock.json
generated
11
mobile/package-lock.json
generated
|
|
@ -32,6 +32,7 @@
|
|||
"react": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-gesture-handler": "~2.20.0",
|
||||
"react-native-google-cast": "^4.9.1",
|
||||
"react-native-qrcode-svg": "^6.3.21",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
|
|
@ -10158,6 +10159,16 @@
|
|||
"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": {
|
||||
"version": "6.3.21",
|
||||
"resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.21.tgz",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"react": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-gesture-handler": "~2.20.0",
|
||||
"react-native-google-cast": "^4.9.1",
|
||||
"react-native-qrcode-svg": "^6.3.21",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* 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.
|
||||
*/
|
||||
import { APP_VERSION, isNewerVersion } from './lib/appVersion';
|
||||
|
||||
export type ChangelogEntry = {
|
||||
/** Stabilny, rosnący identyfikator (np. data + seq). Newest-first w CHANGELOG. */
|
||||
id: string;
|
||||
|
|
@ -13,9 +15,33 @@ export type ChangelogEntry = {
|
|||
date: string;
|
||||
/** Punkty zmian, krótkie, user-facing. */
|
||||
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[] = [
|
||||
{
|
||||
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',
|
||||
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). */
|
||||
export function unseenEntries(lastSeenId: string | null): ChangelogEntry[] {
|
||||
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);
|
||||
if (idx === -1) return [CHANGELOG[0]]; // nieznany marker → pokaż tylko najnowszy
|
||||
return CHANGELOG.slice(0, idx);
|
||||
if (idx === -1) return [visible[0]]; // nieznany marker → pokaż tylko najnowszy
|
||||
const seenIds = new Set(CHANGELOG.slice(idx).map((e) => e.id));
|
||||
return visible.filter((e) => !seenIds.has(e.id));
|
||||
}
|
||||
|
|
|
|||
84
mobile/src/components/CastControl.tsx
Normal file
84
mobile/src/components/CastControl.tsx
Normal 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
81
mobile/src/lib/cast.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import * as Haptics from 'expo-haptics';
|
|||
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||
import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { CastControl, useCastAvailable } from '../components/CastControl';
|
||||
import { pickCastTarget } from '../lib/cast';
|
||||
import { setLastPlayback } from '../lib/lastPlayback';
|
||||
import { allowScreenOff, keepScreenOn } from '../lib/keepAwake';
|
||||
import { usePreferences } from '../PreferencesContext';
|
||||
|
|
@ -235,6 +237,31 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
|||
p.muted = true;
|
||||
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 toggleMute = React.useCallback(() => {
|
||||
const next = !player.muted;
|
||||
|
|
@ -1018,6 +1045,14 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
|||
<Text style={styles.iconText}>⚙</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{castTarget && castAvailable ? (
|
||||
<CastControl
|
||||
target={castTarget}
|
||||
title={title}
|
||||
startTimeSec={player.currentTime || 0}
|
||||
onCastStarted={onCastStarted}
|
||||
/>
|
||||
) : null}
|
||||
<Pressable onPress={copyLink} hitSlop={16} style={styles.iconBtn}>
|
||||
<Text style={styles.iconText}>🔗</Text>
|
||||
</Pressable>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue