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.
81 lines
3.4 KiB
TypeScript
81 lines
3.4 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|