goon/mobile/src/components/CastControl.tsx
goon-foss 0def1a3619
Some checks are pending
Backend tests / test (push) Waiting to run
fix(mobile): PIN sklejany miedzy etapami, Forgot PIN, Cast na Huawei, wibracje w Edge
PIN: kazdy etap (obecny, nowy, potwierdzenie) to ten sam PinEntry w tym samym
miejscu, wiec React zostawial wpisane cyfry. Potwierdzenie startowalo z wpisanym
juz PIN-em, a przy zmianie PIN-u nowe cyfry doklejaly sie do starych i zapisywal
sie sklejony PIN (zgloszenie a8d85930 "I forgot my pin"). key={stage}. Emulator:
na paczce z produkcji 1234 dwa razy = "PINs do not match", z poprawka zapisane.

Forgot PIN? przy zmianie i wylaczaniu PIN-u: odcisk albo blokada ekranu
telefonu (expo-local-authentication z device fallback); bez blokady komunikat.
Sprawdzone na emulatorze w obu wariantach.

Cast: Huawei z EMUI i uslugami Google (YAL-L21, Android 10) mial przy kazdym
otwarciu filmu krach "background can not be translucent" w MediaRouteButton
(GOON-82, 7 zdarzen). Przycisk Cast wylaczony na Huawei.

Edge: bez wibracji przy przewinieciu (zgloszenie 12db400a).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:14:36 +02:00

99 lines
4.1 KiB
TypeScript

/**
* 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, Platform } from 'react-native';
import { CastButton, CastContext, useRemoteMediaClient } from 'react-native-google-cast';
import type { CastTarget } from '../lib/cast';
import { theme } from '../theme';
/**
* Huawei z EMUI i uslugami Google (Honor 20, YAL-L21, Android 10): nakladka systemowa daje
* polprzezroczysty kolor motywu, a MediaRouteButton przy tworzeniu liczy na nim kontrast
* (androidx ColorUtils.calculateContrast) i rzuca "background can not be translucent".
* Aplikacja padala przy KAZDYM otwarciu filmu (GOON-82, 2026-10-08). Bez przycisku Cast
* film gra normalnie; na nowszych Huawei bez uslug Google Cast i tak nie dziala.
*/
function castCrashesOnThisDevice(): boolean {
if (Platform.OS !== 'android') return false;
const c = Platform.constants as { Manufacturer?: string; Brand?: string };
const maker = `${c.Manufacturer ?? ''} ${c.Brand ?? ''}`.toLowerCase();
return maker.includes('huawei');
}
/** 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;
if (castCrashesOnThisDevice()) return;
(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} />;
}