Chromecast improvements (#1657)

This commit is contained in:
2026-08-11 23:58:12 +02:00
committed by GitHub
7 changed files with 123 additions and 19 deletions
+10 -1
View File
@@ -24,6 +24,8 @@ export type VideoMeta = {
title: string;
poster: string | null;
blurhash: string | null;
previousSlug: string | null;
nextSlug: string | null;
};
export const fetchVideoInfo = async (
@@ -86,6 +88,8 @@ type ApiVideo = {
name?: string;
poster?: ApiImage | null;
} | null;
previous?: { video?: string | null } | null;
next?: { video?: string | null } | null;
};
const entryDisplayNumber = (entry: ApiEntry): string => {
@@ -108,7 +112,10 @@ export const fetchVideoMeta = async (
presign?: string,
): Promise<VideoMeta> => {
const res = await fetch(
withPresign(`${apiUrl}/api/videos/${slug}?with=show`, presign),
withPresign(
`${apiUrl}/api/videos/${slug}?with=show,previous,next`,
presign,
),
);
if (!res.ok) throw new Error(`video request failed: ${res.status}`);
const data = (await res.json()) as ApiVideo;
@@ -135,5 +142,7 @@ export const fetchVideoMeta = async (
? withPresign(`${apiUrl}/api/images/${poster.id}?quality=high`, presign)
: null,
blurhash: poster?.blurhash ?? null,
previousSlug: data.previous?.video ?? null,
nextSlug: data.next?.video ?? null,
};
};
+98 -13
View File
@@ -6,16 +6,24 @@ import { SubtitleManager } from "./subtitles";
import { ReceiverUi } from "./ui";
const { EventType, DetailedErrorCode } = cast.framework.events;
const { EventType: SystemEventType } = cast.framework.system;
const {
MessageType,
Command,
HlsSegmentFormat,
HlsVideoSegmentFormat,
GenericMediaMetadata,
Track,
TrackType,
TextTrackType,
TextTrackStyle,
TextTrackEdgeType,
TextTrackFontGenericFamily,
} = cast.framework.messages;
// prev/next requests we forward to the senders (react-native-omni listens to it)
const KYOO_NAMESPACE = "urn:x-cast:dev.zoriya.omni";
export const filterMasterPlaylist = (
text: string,
baseUrl: string,
@@ -69,6 +77,7 @@ export class KyooReceiver {
document.getElementById("subtitle-layer") as HTMLElement,
);
#progress = new ProgressObserver(this.#player);
#idleTimer: ReturnType<typeof setTimeout> | undefined;
start(): void {
this.#ui.hideCafChrome();
@@ -76,7 +85,35 @@ export class KyooReceiver {
this.#progress.start();
this.#playbackConfig.initialBandwidth = 20_000_000;
this.#playbackConfig.segmentRequestRetryLimit = 4;
const retryParameters = {
maxAttempts: 5,
baseDelay: 1000,
backoffFactor: 2,
fuzzFactor: 0.5,
connectionTimeout: 0,
stallTimeout: 0,
timeout: 90_000,
};
this.#playbackConfig.shakaConfig = {
manifest: { retryParameters },
streaming: { retryParameters },
};
this.#player.setMessageInterceptor(MessageType.LOAD, this.#onLoad);
// we never queue anything, so caf has nothing to skip to: forward the
// intent to the senders instead, they are the ones knowing the next entry
// & they load it like they do for their own prev/next buttons.
this.#player.setMessageInterceptor(MessageType.QUEUE_UPDATE, (request) => {
const jump = request.jump ?? 0;
if (jump || request.currentItemId !== undefined) {
const action = jump < 0 ? "prev" : "next";
console.log(
`[kyoo-receiver] asking senders to play the ${action} entry`,
);
this.#context.sendCustomMessage(KYOO_NAMESPACE, undefined, { action });
}
return null;
});
this.#player.setMessageInterceptor(
MessageType.MEDIA_STATUS,
this.#onStatus,
@@ -87,7 +124,10 @@ export class KyooReceiver {
});
this.#player.addEventListener(EventType.MEDIA_FINISHED, (e) => {
this.#subtitles.clear();
if (e.endedReason === "ERROR") this.#player.stop();
// if no new item is enqueued, reset the ui
// (will be cancelled if a request comes in)
if (e.endedReason === "END_OF_STREAM")
this.#idleTimer = setTimeout(() => this.#ui.reset(), 5_000);
});
this.#player.addEventListener(EventType.ERROR, (e) => {
if (e.detailedErrorCode === DetailedErrorCode.LOAD_INTERRUPTED) {
@@ -98,9 +138,20 @@ export class KyooReceiver {
this.#player.stop();
});
// senders that lost the app can't answer a skip, don't offer it anymore
this.#context.addEventListener(SystemEventType.SENDER_DISCONNECTED, () => {
if (this.#context.getSenders().length === 0)
this.#player.removeSupportedMediaCommands(
Command.QUEUE_PREV | Command.QUEUE_NEXT,
);
});
const options = new cast.framework.CastReceiverOptions();
options.playbackConfig = this.#playbackConfig;
options.maxInactivity = 3600;
options.customNamespaces = {
[KYOO_NAMESPACE]: cast.framework.system.MessageType.JSON,
};
this.#context.start(options);
}
@@ -122,6 +173,7 @@ export class KyooReceiver {
copied.trackContentId = undefined;
return copied;
});
stripped.textTrackStyle = undefined;
return stripped;
};
@@ -140,6 +192,7 @@ export class KyooReceiver {
request: messages.LoadRequestData,
): Promise<messages.LoadRequestData> => {
const data = (asObject(request.media?.customData) as KyooCastData) ?? {};
clearTimeout(this.#idleTimer);
this.#ui.clearError();
this.#ui.dismissSplash();
@@ -158,24 +211,48 @@ export class KyooReceiver {
request.media.hlsVideoSegmentFormat = HlsVideoSegmentFormat.FMP4;
}
if (request.media) {
const style = new TextTrackStyle();
style.foregroundColor = "#FFFFFFFF";
style.backgroundColor = "#00000000";
style.edgeType = TextTrackEdgeType.OUTLINE;
style.edgeColor = "#000000FF";
style.fontFamily = "Poppins";
style.fontGenericFamily = TextTrackFontGenericFamily.SANS_SERIF;
request.media.textTrackStyle = style;
}
// Fetch the master ourselves to:
// - resolve the 302 (relative urls resolve against the pre-redirect url)
// (https://github.com/shaka-project/shaka-player/issues/2679)
// - drop variants the device can't decode
// (https://github.com/shaka-project/shaka-player/issues/9211)
if (request.media?.contentUrl) {
try {
const res = await fetch(request.media.contentUrl, {
redirect: "follow",
});
const finalUrl =
res.redirected && res.url ? res.url : request.media.contentUrl;
const filtered = filterMasterPlaylist(await res.text(), finalUrl);
request.media.contentUrl = filtered
? `data:application/vnd.apple.mpegurl,${encodeURIComponent(filtered)}`
: finalUrl;
} catch (e) {
console.error("[kyoo-receiver] manifest fetch/filter failed", e);
const masterUrl = request.media.contentUrl;
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(masterUrl, { redirect: "follow" });
if (!res.ok) throw new Error(`master request failed: ${res.status}`);
const finalUrl = res.redirected && res.url ? res.url : masterUrl;
const filtered = filterMasterPlaylist(await res.text(), finalUrl);
request.media.contentUrl = filtered
? `data:application/vnd.apple.mpegurl,${encodeURIComponent(filtered)}`
: finalUrl;
break;
} catch (e) {
if (attempt >= 4) {
console.error("[kyoo-receiver] manifest fetch/filter failed", e);
break;
}
console.warn(
`[kyoo-receiver] manifest fetch failed (attempt ${attempt + 1}), retrying`,
e,
);
const base = Math.min(1000 * 2 ** attempt, 10_000);
await new Promise((r) =>
setTimeout(r, base / 2 + Math.random() * (base / 2)),
);
}
}
}
@@ -207,6 +284,14 @@ export class KyooReceiver {
try {
const meta = await fetchVideoMeta(data.apiUrl, data.slug, data.presign);
this.#ui.setMetadata(meta);
this.#player.setSupportedMediaCommands(
Command.PAUSE |
Command.SEEK |
Command.STREAM_VOLUME |
Command.STREAM_MUTE |
(meta.previousSlug ? Command.QUEUE_PREV : 0) |
(meta.nextSlug ? Command.QUEUE_NEXT : 0),
);
if (meta.videoId && meta.entryId) {
this.#progress.load(data, {
videoId: meta.videoId,
+8
View File
@@ -133,6 +133,14 @@ export class ReceiverUi {
this.#el.splash.classList.add("gone");
}
reset(): void {
this.#el.splash.classList.remove("gone");
this.clearMetadata();
this.setLoading(false);
this.setPaused(false);
this.show({ sticky: true });
}
setMetadata({
showName,
entryName,
+2 -2
View File
@@ -46,7 +46,7 @@
"react-native-localization-settings": "^1.2.0",
"react-native-mmkv": "^4.3.1",
"react-native-nitro-modules": "^0.35.7",
"react-native-omni": "^1.11.3",
"react-native-omni": "^1.11.4",
"react-native-pager-view": "8.0.2",
"react-native-reanimated": "4.5.0",
"react-native-safe-area-context": "~5.7.0",
@@ -1279,7 +1279,7 @@
"react-native-nitro-modules": ["react-native-nitro-modules@0.35.10", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-KsySOAIkbSTjNiX2GvXiNT9P5DMeZd99yvtE/+Lw7RXV7Ztxh9Z+YyAbYkBqadhN5J/KXXuPjhRYgyLOIZgsbQ=="],
"react-native-omni": ["react-native-omni@1.11.3", "", { "dependencies": { "@expo/config-plugins": "^57.0.3", "@videojs/core": "github:zoriya/v10#build26-core", "@videojs/media": "github:zoriya/v10#build26-media", "@videojs/react": "github:zoriya/v10#build26-react", "@videojs/spf": "github:zoriya/v10#build26-spf", "@videojs/store": "github:zoriya/v10#build26-store", "@videojs/utils": "github:zoriya/v10#build26-utils", "jassub": "^2.5.7", "libpgs": "^0.8.1" }, "peerDependencies": { "react": ">=19.0.0", "react-native": ">=0.86.0", "react-native-nitro-modules": ">=0.36.0" } }, "sha512-A2Mw07v5pSF4wvxz/NFfQhvpu512AMrGazZ+MRm/oYCvSnJyonQXvJ0cBvtaBJeYTAq1rPlBrRX4MgCIChyF+g=="],
"react-native-omni": ["react-native-omni@1.11.4", "", { "dependencies": { "@expo/config-plugins": "^57.0.3", "@videojs/core": "github:zoriya/v10#build26-core", "@videojs/media": "github:zoriya/v10#build26-media", "@videojs/react": "github:zoriya/v10#build26-react", "@videojs/spf": "github:zoriya/v10#build26-spf", "@videojs/store": "github:zoriya/v10#build26-store", "@videojs/utils": "github:zoriya/v10#build26-utils", "jassub": "^2.5.7", "libpgs": "^0.8.1" }, "peerDependencies": { "react": ">=19.0.0", "react-native": ">=0.86.0", "react-native-nitro-modules": ">=0.36.0" } }, "sha512-os7HCoTJXC/iOjds3FkGx41EmkOWPGDfX4QISrYfZfty0Tw30ESLHOvsLpq0IPrvJ2bxm6cTdDKdjsj2JJ01Lw=="],
"react-native-pager-view": ["react-native-pager-view@8.0.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Y8All2BbjidI4ZIF9kBOIIoldkqrY/2FXapHZMdjwD+ByXhiOWTFenPs5rq9KRN5uAH/gOXtQCqHLxQDNBQRiQ=="],
+1 -1
View File
@@ -58,7 +58,7 @@
"react-native-localization-settings": "^1.2.0",
"react-native-mmkv": "^4.3.1",
"react-native-nitro-modules": "^0.35.7",
"react-native-omni": "^1.11.3",
"react-native-omni": "^1.11.4",
"react-native-pager-view": "8.0.2",
"react-native-reanimated": "4.5.0",
"react-native-safe-area-context": "~5.7.0",
+2
View File
@@ -102,3 +102,5 @@ require (
golang.org/x/text v0.40.0
golang.org/x/time v0.15.0 // indirect
)
replace github.com/asticode/go-astisub => github.com/zoriya/go-astisub v0.42.1-0.20260810114112-a5151634cd4e
+2 -2
View File
@@ -14,8 +14,6 @@ github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xbl
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astikit v0.59.0 h1:tjbwDym+MTSxqkAhJoHRZmHMXK6Jv4vGx+97FptKH6k=
github.com/asticode/go-astikit v0.59.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
github.com/asticode/go-astisub v0.42.0 h1:IJuJJTQwCSFeY9bx1kTzdCwkIeG27wj4CAh6nHc+pvY=
github.com/asticode/go-astisub v0.42.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
github.com/asticode/go-astits v1.15.0 h1:yRyCiUc8Jj4F7clt2GDxHghMpWuFL5rkaLuGUd2/0J4=
github.com/asticode/go-astits v1.15.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
@@ -184,6 +182,8 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk=
github.com/swaggo/swag/v2 v2.0.0-rc5/go.mod h1:kCL8Fu4Zl8d5tB2Bgj96b8wRowwrwk175bZHXfuGVFI=
github.com/zoriya/go-astisub v0.42.1-0.20260810114112-a5151634cd4e h1:nlZ82WW9dGqfFtV+hmETkASo86dm0u0gaqSYpVJgafQ=
github.com/zoriya/go-astisub v0.42.1-0.20260810114112-a5151634cd4e/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
gitlab.com/opennota/screengen v1.0.2 h1:GxYTJdAPEzmg5v5CV4dgn45JVW+EcXXAvCxhE7w6UDw=
gitlab.com/opennota/screengen v1.0.2/go.mod h1:4kED4yriw2zslwYmXFCa5qCvEKwleBA7l5OE+d94NTU=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=