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 `