diff --git a/chromecast/src/api.ts b/chromecast/src/api.ts index da04792db..081653b11 100644 --- a/chromecast/src/api.ts +++ b/chromecast/src/api.ts @@ -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 => { 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, }; }; diff --git a/chromecast/src/receiver.ts b/chromecast/src/receiver.ts index abfeecb92..491474bf9 100644 --- a/chromecast/src/receiver.ts +++ b/chromecast/src/receiver.ts @@ -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 | 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 => { 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, diff --git a/chromecast/src/ui.ts b/chromecast/src/ui.ts index f89178368..f8cb1111c 100644 --- a/chromecast/src/ui.ts +++ b/chromecast/src/ui.ts @@ -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, diff --git a/front/bun.lock b/front/bun.lock index c9984c111..45a88ac7f 100644 --- a/front/bun.lock +++ b/front/bun.lock @@ -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=="], diff --git a/front/package.json b/front/package.json index 64f640451..263c0a7e4 100644 --- a/front/package.json +++ b/front/package.json @@ -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", diff --git a/transcoder/go.mod b/transcoder/go.mod index 59866fd48..7c205f43f 100644 --- a/transcoder/go.mod +++ b/transcoder/go.mod @@ -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 diff --git a/transcoder/go.sum b/transcoder/go.sum index 9592902ad..d86cfdae7 100644 --- a/transcoder/go.sum +++ b/transcoder/go.sum @@ -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=