diff --git a/app/api/stream_proxy.py b/app/api/stream_proxy.py index 6c6cf7e..4ed0183 100644 --- a/app/api/stream_proxy.py +++ b/app/api/stream_proxy.py @@ -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 diff --git a/app/main.py b/app/main.py index 6a37633..2a645a4 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 161bd53..e9b0d56 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -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"; diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 94dd2a2..00a106b 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -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. --> - + + + + + + diff --git a/mobile/android/app/src/main/java/com/goon/mobile/MainActivity.kt b/mobile/android/app/src/main/java/com/goon/mobile/MainActivity.kt index 2d6f388..b27e53a 100644 --- a/mobile/android/app/src/main/java/com/goon/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/java/com/goon/mobile/MainActivity.kt @@ -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) + } } /** diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index abbcb8e..a45ed27 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -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() diff --git a/mobile/app.json b/mobile/app.json index 53e4a6d..046407c 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -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", diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 9348f39..36e3c00 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -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", diff --git a/mobile/package.json b/mobile/package.json index 55ee5db..398535f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 593bbd0..03b3717 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -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)); } diff --git a/mobile/src/components/CastControl.tsx b/mobile/src/components/CastControl.tsx new file mode 100644 index 0000000..7f87587 --- /dev/null +++ b/mobile/src/components/CastControl.tsx @@ -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(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 ; +} diff --git a/mobile/src/lib/cast.ts b/mobile/src/lib/cast.ts new file mode 100644 index 0000000..d26a442 --- /dev/null +++ b/mobile/src/lib/cast.ts @@ -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 | 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 `