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",
|
"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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
11
mobile/package-lock.json
generated
11
mobile/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 * 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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue