diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 829062f5..594b0081 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -49,8 +49,7 @@ "minSdkVersion": 30 } } - ], - "expo-network" + ] ], "experiments": { "typedRoutes": true diff --git a/apps/mobile/modules/streamfusion-native-contracts/android/build.gradle b/apps/mobile/modules/streamfusion-native-contracts/android/build.gradle index 801fdad0..073333c8 100644 --- a/apps/mobile/modules/streamfusion-native-contracts/android/build.gradle +++ b/apps/mobile/modules/streamfusion-native-contracts/android/build.gradle @@ -17,4 +17,7 @@ android { dependencies { implementation "com.squareup.okhttp3:okhttp:4.12.0" + implementation "androidx.media3:media3-exoplayer:1.5.1" + implementation "androidx.media3:media3-exoplayer-hls:1.5.1" + implementation "androidx.media3:media3-ui:1.5.1" } diff --git a/apps/mobile/modules/streamfusion-native-contracts/android/src/main/java/expo/modules/streamfusionnativecontracts/FocusedPlaybackSessionOwner.kt b/apps/mobile/modules/streamfusion-native-contracts/android/src/main/java/expo/modules/streamfusionnativecontracts/FocusedPlaybackSessionOwner.kt new file mode 100644 index 00000000..aeb403b5 --- /dev/null +++ b/apps/mobile/modules/streamfusion-native-contracts/android/src/main/java/expo/modules/streamfusionnativecontracts/FocusedPlaybackSessionOwner.kt @@ -0,0 +1,253 @@ +package expo.modules.streamfusionnativecontracts + +import android.content.Context +import android.net.Uri +import android.os.Handler +import android.os.Looper +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import java.util.concurrent.CountDownLatch + +object FocusedPlaybackSessionOwner { + private val SESSION_ID = Regex("^[a-zA-Z0-9._:-]{1,256}$") + private val lock = Any() + private val main = Handler(Looper.getMainLooper()) + private val views = mutableSetOf() + private var player: ExoPlayer? = null + private var activeSessionId: String? = null + private var emit: ((Map) -> Unit)? = null + + fun attachEmitter(next: (Map) -> Unit) { + synchronized(lock) { emit = next } + } + + fun start(context: Context, request: Map): Map = onMain { + val sessionId = request["sessionId"] as? String + val sourceUri = request["sourceUri"] as? String + if (sessionId.isNullOrBlank() || !SESSION_ID.matches(sessionId)) { + return@onMain mapOf("kind" to "invalid") + } + if (sourceUri.isNullOrBlank() || !isHttpsHls(sourceUri)) { + return@onMain mapOf("kind" to "invalid") + } + val exo = ExoPlayer.Builder(context.applicationContext).build() + exo.addListener(SessionListener(sessionId)) + exo.setMediaItem( + MediaItem.Builder() + .setUri(sourceUri) + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build(), + ) + val previous = synchronized(lock) { + val outgoing = player + views.forEach { it.detachPlayer() } + player = exo + activeSessionId = sessionId + outgoing + } + previous?.release() + exo.prepare() + exo.playWhenReady = true + synchronized(lock) { + if (player !== exo) { + return@onMain mapOf("kind" to "invalid") + } + bindViewsLocked() + } + mapOf( + "kind" to "completed", + "value" to mapOf( + "pictureInPictureEligible" to false, + "sessionId" to sessionId, + ), + ) + } + + fun end(sessionId: String): Map = onMain { + val outgoing = synchronized(lock) { + if (activeSessionId != sessionId) { + return@onMain mapOf( + "kind" to "completed", + "value" to mapOf("kind" to "missing", "sessionId" to sessionId), + ) + } + val current = player + views.forEach { it.detachPlayer() } + player = null + activeSessionId = null + current + } + outgoing?.release() + mapOf( + "kind" to "completed", + "value" to mapOf( + "kind" to "ended", + "state" to mapOf( + "pictureInPictureEligible" to false, + "sessionId" to sessionId, + ), + ), + ) + } + + fun pauseForBackground() = onMain { + val current = synchronized(lock) { player } + current?.playWhenReady = false + } + + fun release() = onMain { + val outgoing = synchronized(lock) { + views.forEach { it.detachPlayer() } + val current = player + player = null + activeSessionId = null + current + } + outgoing?.release() + } + + fun register(view: StreamFusionPlaybackView) = onMain { + synchronized(lock) { + views.add(view) + bindViewsLocked() + } + } + + fun unregister(view: StreamFusionPlaybackView) = onMain { + synchronized(lock) { + views.remove(view) + view.detachPlayer() + } + } + + fun bindIfMatches(view: StreamFusionPlaybackView, sessionId: String?) = onMain { + synchronized(lock) { + if (sessionId != null && sessionId == activeSessionId) { + view.attachPlayer(player) + } else { + view.detachPlayer() + } + } + } + + private fun bindViewsLocked() { + val current = player + val sessionId = activeSessionId + views.forEach { view -> + if (sessionId != null && view.boundSessionId() == sessionId) { + view.attachPlayer(current) + } else { + view.detachPlayer() + } + } + } + + private fun isHttpsHls(sourceUri: String): Boolean { + val uri = Uri.parse(sourceUri) + val path = uri.path.orEmpty().lowercase() + return uri.scheme == "https" && path.contains(".m3u8") + } + + private fun publish(event: Map) { + main.post { emit?.invoke(event) } + } + + private fun onMain(block: () -> T): T { + if (Looper.myLooper() == Looper.getMainLooper()) { + return block() + } + val done = CountDownLatch(1) + var result: T? = null + var error: Throwable? = null + main.post { + try { + result = block() + } catch (failure: Throwable) { + error = failure + } finally { + done.countDown() + } + } + done.await() + error?.let { throw it } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private class SessionListener( + private val sessionId: String, + ) : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + val current = synchronized(lock) { activeSessionId } + if (current != sessionId) return + when (playbackState) { + Player.STATE_BUFFERING -> publish(mapOf("kind" to "buffering", "sessionId" to sessionId)) + Player.STATE_READY -> { + val playing = synchronized(lock) { player?.playWhenReady == true } + if (playing) { + publish(mapOf("kind" to "playing", "sessionId" to sessionId)) + } else { + publish( + mapOf( + "kind" to "paused", + "sessionId" to sessionId, + "reason" to "user", + ), + ) + } + } + Player.STATE_ENDED -> publish(mapOf("kind" to "ended", "sessionId" to sessionId)) + } + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + val current = synchronized(lock) { activeSessionId } + if (current != sessionId) return + if (playWhenReady) { + publish(mapOf("kind" to "playing", "sessionId" to sessionId)) + return + } + val pausedReason = + if (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS) { + "background" + } else { + "user" + } + publish( + mapOf( + "kind" to "paused", + "sessionId" to sessionId, + "reason" to pausedReason, + ), + ) + } + + override fun onPlayerError(error: PlaybackException) { + val current = synchronized(lock) { activeSessionId } + if (current != sessionId) return + publish( + mapOf( + "kind" to "failed", + "sessionId" to sessionId, + "code" to failureCode(error), + "detail" to "Focused playback stopped.", + ), + ) + } + } + + private fun failureCode(error: PlaybackException): String { + val message = error.errorCodeName.lowercase() + return when { + message.contains("decoder") -> "PLAYBACK_DECODER_UNSUPPORTED" + message.contains("http") || message.contains("network") || message.contains("timeout") -> + "PLAYBACK_NETWORK_FAILED" + message.contains("parsing") || message.contains("format") || message.contains("manifest") -> + "PLAYBACK_SOURCE_REJECTED" + else -> "PLAYBACK_UNKNOWN" + } + } +} diff --git a/apps/mobile/modules/streamfusion-native-contracts/android/src/main/java/expo/modules/streamfusionnativecontracts/StreamFusionPlaybackModule.kt b/apps/mobile/modules/streamfusion-native-contracts/android/src/main/java/expo/modules/streamfusionnativecontracts/StreamFusionPlaybackModule.kt index e2f060cb..d26f8323 100644 --- a/apps/mobile/modules/streamfusion-native-contracts/android/src/main/java/expo/modules/streamfusionnativecontracts/StreamFusionPlaybackModule.kt +++ b/apps/mobile/modules/streamfusion-native-contracts/android/src/main/java/expo/modules/streamfusionnativecontracts/StreamFusionPlaybackModule.kt @@ -1,15 +1,42 @@ package expo.modules.streamfusionnativecontracts +import expo.modules.kotlin.functions.Queues import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class StreamFusionPlaybackModule : Module() { override fun definition() = ModuleDefinition { Name("StreamFusionPlayback") - Function("getContractVersion") { 1 } - AsyncFunction("startFocusedSession") { _: Map -> unsupported("Focused playback sessions") } - AsyncFunction("enterPictureInPicture") { _: String -> unsupported("Picture in Picture") } - AsyncFunction("endFocusedSession") { _: String -> unsupported("Focused playback sessions") } + Events("onNativePlayback") + Function("getContractVersion") { 2 } + OnCreate { + FocusedPlaybackSessionOwner.attachEmitter { event -> + sendEvent("onNativePlayback", event) + } + } + OnDestroy { + FocusedPlaybackSessionOwner.release() + } + OnActivityEntersBackground { + FocusedPlaybackSessionOwner.pauseForBackground() + } + AsyncFunction("startFocusedSession") { request: Map -> + val context = requireNotNull(appContext.reactContext) { + "Focused playback requires a React application context." + } + FocusedPlaybackSessionOwner.start(context, request) + }.runOnQueue(Queues.MAIN) + AsyncFunction("endFocusedSession") { sessionId: String -> + FocusedPlaybackSessionOwner.end(sessionId) + }.runOnQueue(Queues.MAIN) + AsyncFunction("enterPictureInPicture") { _: String -> + unsupported("Picture in Picture") + } + View(StreamFusionPlaybackView::class) { + Prop("sessionId") { view: StreamFusionPlaybackView, sessionId: String? -> + view.setSessionId(sessionId) + } + } } private fun unsupported(operation: String) = mapOf( diff --git a/apps/mobile/modules/streamfusion-native-contracts/android/src/main/java/expo/modules/streamfusionnativecontracts/StreamFusionPlaybackView.kt b/apps/mobile/modules/streamfusion-native-contracts/android/src/main/java/expo/modules/streamfusionnativecontracts/StreamFusionPlaybackView.kt new file mode 100644 index 00000000..72b5ba2e --- /dev/null +++ b/apps/mobile/modules/streamfusion-native-contracts/android/src/main/java/expo/modules/streamfusionnativecontracts/StreamFusionPlaybackView.kt @@ -0,0 +1,44 @@ +package expo.modules.streamfusionnativecontracts + +import android.content.Context +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.views.ExpoView + +class StreamFusionPlaybackView( + context: Context, + appContext: AppContext, +) : ExpoView(context, appContext) { + private val playerView = PlayerView(context).apply { + useController = true + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + private var sessionId: String? = null + + init { + addView(playerView) + FocusedPlaybackSessionOwner.register(this) + } + + fun boundSessionId(): String? = sessionId + + fun setSessionId(next: String?) { + sessionId = next + FocusedPlaybackSessionOwner.bindIfMatches(this, next) + } + + fun attachPlayer(player: ExoPlayer?) { + playerView.player = player + } + + fun detachPlayer() { + playerView.player = null + } + + override fun onDetachedFromWindow() { + detachPlayer() + FocusedPlaybackSessionOwner.unregister(this) + super.onDetachedFromWindow() + } +} diff --git a/apps/mobile/src/composition/mobile-runtime.tsx b/apps/mobile/src/composition/mobile-runtime.tsx index ab4f4c10..ddfdcd8c 100644 --- a/apps/mobile/src/composition/mobile-runtime.tsx +++ b/apps/mobile/src/composition/mobile-runtime.tsx @@ -55,6 +55,7 @@ import { createSearchHistoryRepository } from "@mobile/features/discovery/compos import { createDiscoveryPreferenceStore } from "@mobile/features/discovery/data/discovery-preference-store"; import { createFollowingRuntime } from "@mobile/features/follows/composition/following-runtime"; import { createConnectivityRuntime } from "@mobile/features/connectivity/composition/connectivity-runtime"; +import { createGuestWatchScreen } from "@mobile/features/watch/composition/guest-watch-screen"; const androidCapabilityRuntime = createAndroidCapabilityContractRuntime(); @@ -328,6 +329,18 @@ export function MobileRuntime() { }), [useDevelopmentKickFixture, useDevelopmentTwitchFixture], ); + const watch = useMemo( + () => + createGuestWatchScreen({ + discovery: homeDiscovery, + fetch: connectivitySession.fetch, + playback: androidCapabilityRuntime.contracts.playback, + policyStore: installationPolicyRuntime.policyStore, + sessionIds: { create: secureRandom.uuid }, + }), + [homeDiscovery], + ); + useEffect(() => () => void watch.runtime.session.dispose(), [watch]); useEffect(() => { if (!developmentActivityProof) return; const unsubscribe = developmentActivityProof.subscribe(setActivityProof); @@ -415,6 +428,7 @@ export function MobileRuntime() { discoveryPreferences={discoveryPreferences} followingSession={followingSession} connectivitySession={connectivitySession} + watch={watch} /> ); diff --git a/apps/mobile/src/features/discovery/capabilities/platform-reads.ts b/apps/mobile/src/features/discovery/capabilities/platform-reads.ts index d663ed0e..72502bd3 100644 --- a/apps/mobile/src/features/discovery/capabilities/platform-reads.ts +++ b/apps/mobile/src/features/discovery/capabilities/platform-reads.ts @@ -173,10 +173,19 @@ export type FollowView = | { readonly kind: "pending" } | { readonly kind: "failed"; readonly reason: string }; -export type WatchAvailability = { - readonly kind: "unavailable"; - readonly reason: string; -}; +export type WatchAvailability = + | { + readonly kind: "available"; + readonly target: { + readonly channelId: string; + readonly channelName: string; + readonly platform: Platform; + }; + } + | { + readonly kind: "unavailable"; + readonly reason: "channel-offline" | "live-state-unverified"; + }; export type ChannelDetailTab = "home" | "videos" | "clips"; diff --git a/apps/mobile/src/features/discovery/components/channel-detail-screen.tsx b/apps/mobile/src/features/discovery/components/channel-detail-screen.tsx index f58dfff3..9e44cadc 100644 --- a/apps/mobile/src/features/discovery/components/channel-detail-screen.tsx +++ b/apps/mobile/src/features/discovery/components/channel-detail-screen.tsx @@ -31,10 +31,16 @@ import { useChannelFollow } from "./use-channel-follow"; export function ChannelDetailScreen({ channel, following, + onWatch, session, }: { readonly channel: ChannelIdentity; readonly following: FollowingSession; + readonly onWatch?: (target: { + readonly channelId: string; + readonly channelName: string; + readonly platform: ChannelIdentity["platform"]; + }) => void; readonly session: DiscoverySession; }) { const [mode, setMode] = useState("live"); @@ -59,6 +65,7 @@ export function ChannelDetailScreen({ onOpenProviderPage={follow.openProviderPage} onRetry={live.retry} view={view} + {...(onWatch === undefined ? {} : { onWatch })} {...(__DEV__ ? { onSelectProofMode: setMode, proofMode: mode } : {})} /> ); @@ -70,6 +77,7 @@ export function ChannelDetailView({ onOpenProviderPage, onRetry, onSelectProofMode, + onWatch, proofMode, view, }: { @@ -78,6 +86,11 @@ export function ChannelDetailView({ readonly onOpenProviderPage: () => void; readonly onRetry: () => void; readonly onSelectProofMode?: (mode: ChannelFixtureMode) => void; + readonly onWatch?: (target: { + readonly channelId: string; + readonly channelName: string; + readonly platform: ChannelIdentity["platform"]; + }) => void; readonly proofMode?: ChannelFixtureMode; readonly view: ChannelDetailModel; }) { @@ -90,6 +103,7 @@ export function ChannelDetailView({ onRetry={onRetry} onSelectTab={setTab} tab={tab} + {...(onWatch === undefined ? {} : { onWatch })} view={view} {...(onSelectProofMode === undefined ? {} @@ -105,6 +119,7 @@ export function ChannelDetailBody({ onRetry, onSelectProofMode, onSelectTab, + onWatch, proofMode, tab, view, @@ -115,6 +130,11 @@ export function ChannelDetailBody({ readonly onRetry: () => void; readonly onSelectProofMode?: (mode: ChannelFixtureMode) => void; readonly onSelectTab: (tab: ChannelDetailTab) => void; + readonly onWatch?: (target: { + readonly channelId: string; + readonly channelName: string; + readonly platform: ChannelIdentity["platform"]; + }) => void; readonly proofMode?: ChannelFixtureMode; readonly tab: ChannelDetailTab; readonly view: ChannelDetailModel; @@ -146,7 +166,10 @@ export function ChannelDetailBody({ follow={view.follow} onFollow={onFollow} onOpenProviderPage={onOpenProviderPage} - onWatch={() => undefined} + onWatch={() => { + if (view.watch.kind !== "available") return; + onWatch?.(view.watch.target); + }} watch={view.watch} /> ) : null} diff --git a/apps/mobile/src/features/discovery/components/channel-header.tsx b/apps/mobile/src/features/discovery/components/channel-header.tsx index 0d25f58c..6a8e3d1c 100644 --- a/apps/mobile/src/features/discovery/components/channel-header.tsx +++ b/apps/mobile/src/features/discovery/components/channel-header.tsx @@ -8,6 +8,7 @@ import { mobileSpacing, } from "@mobile/design/tokens"; import type { FollowView, WatchAvailability } from "../capabilities/platform-reads"; +import { watchAvailabilityCopy } from "../domain/channel-detail"; import { followActionLabel, followCopy, @@ -35,6 +36,8 @@ export function ChannelHeader({ : `${channel.followerCount} followers`; const followBusy = follow.kind === "pending"; const followLabel = followActionLabel(follow); + const watchEnabled = watch.kind === "available"; + const watchCopy = watchAvailabilityCopy(watch); return ( {channel.avatarUrl ? ( @@ -102,21 +105,24 @@ export function ChannelHeader({ - + Watch - {watch.reason} + {watchCopy} @@ -220,10 +226,18 @@ const styles = StyleSheet.create({ opacity: 0.72, paddingHorizontal: mobileSpacing.medium, }, + watchEnabled: { + backgroundColor: mobileColors.textPrimary, + borderWidth: 0, + opacity: 1, + }, watchLabel: { color: mobileColors.textSecondary, fontSize: 14, fontWeight: "700", lineHeight: 20, }, + watchLabelEnabled: { + color: mobileColors.background, + }, }); diff --git a/apps/mobile/src/features/discovery/domain/channel-detail.ts b/apps/mobile/src/features/discovery/domain/channel-detail.ts index aed69454..df7aea9f 100644 --- a/apps/mobile/src/features/discovery/domain/channel-detail.ts +++ b/apps/mobile/src/features/discovery/domain/channel-detail.ts @@ -12,11 +12,6 @@ import type { } from "../capabilities/platform-reads"; import { composeGuestFollowView } from "./channel-follow"; -export const WATCH_UNAVAILABLE: WatchAvailability = { - kind: "unavailable", - reason: "Watch is not available yet.", -}; - export function composeChannelDetail(input: { readonly clips?: ChannelMediaRead; readonly follow?: FollowView; @@ -45,10 +40,41 @@ export function composeChannelDetail(input: { page, }), videos, - watch: WATCH_UNAVAILABLE, + watch: composeWatchAvailability(page), }; } +export function composeWatchAvailability( + page: ChannelPageOutcome, +): WatchAvailability { + const live = page.live; + const stale = + page.status === "stale" || + (page.cache.kind === "hit" && page.cache.stale); + if (live && live.isLive && !stale) { + return { + kind: "available", + target: { + channelId: live.channelId, + channelName: live.channelName, + platform: live.platform, + }, + }; + } + if (live && live.isLive && stale) { + return { kind: "unavailable", reason: "live-state-unverified" }; + } + return { kind: "unavailable", reason: "channel-offline" }; +} + +export function watchAvailabilityCopy(watch: WatchAvailability): string { + if (watch.kind === "available") return "Opens live Watch for this channel."; + if (watch.reason === "live-state-unverified") { + return "Live state is not verified. Refresh the channel."; + } + return "This channel is not live."; +} + function channelIdentity(page: ChannelPageOutcome): ChannelIdentity { return { id: page.channel?.id ?? "", diff --git a/apps/mobile/src/features/discovery/tests/channel-detail.test.ts b/apps/mobile/src/features/discovery/tests/channel-detail.test.ts index d735a2df..96f58340 100644 --- a/apps/mobile/src/features/discovery/tests/channel-detail.test.ts +++ b/apps/mobile/src/features/discovery/tests/channel-detail.test.ts @@ -49,7 +49,7 @@ function descendants(node: unknown): readonly Element[] { } describe("channel detail compose", () => { - it("defaults Guest Follow to absent and keeps Watch unavailable", () => { + it("defaults Guest Follow to absent and enables Watch for a live stream", () => { const view = composeChannelDetail({ clips: { kind: "page", @@ -75,7 +75,14 @@ describe("channel detail compose", () => { }, }); expect(view.follow).toEqual({ kind: "guest-absent" }); - expect(view.watch.kind).toBe("unavailable"); + expect(view.watch).toEqual({ + kind: "available", + target: { + channelId: "twitch-twitch-ready", + channelName: "twitch-live", + platform: "twitch", + }, + }); expect(view.channel?.displayName).toBe(fixtureChannel("twitch", true).displayName); expect(view.phase).toBe("ready"); }); @@ -141,6 +148,8 @@ describe("channel detail screen", () => { expect(nodes.some((node) => node.props.children === "Open on Twitch")).toBe( true, ); + const watch = nodes.find((node) => node.props.testID === "channel-watch"); + expect(watch?.props.disabled).toBe(false); }); it("disables Follow while a Guest Follow write is pending", () => { diff --git a/apps/mobile/src/features/installation-policy/capabilities/installation-policy.ts b/apps/mobile/src/features/installation-policy/capabilities/installation-policy.ts index 50cf734d..a4944583 100644 --- a/apps/mobile/src/features/installation-policy/capabilities/installation-policy.ts +++ b/apps/mobile/src/features/installation-policy/capabilities/installation-policy.ts @@ -121,3 +121,18 @@ export interface CapabilityPolicyVerifier { export interface InstallationIdentitySource { create(): string; } + +export type EffectiveCapabilityDecision = + | { + readonly kind: "enabled"; + readonly sequence: number; + readonly verifiedAtEpochMs: number; + } + | { + readonly kind: "disabled"; + readonly reason: "expired" | "no-valid-policy" | "not-allowed"; + }; + +export interface EffectiveCapabilityPolicyReader { + read(capabilityId: string): Promise; +} diff --git a/apps/mobile/src/features/installation-policy/domain/effective-capability-policy-reader.ts b/apps/mobile/src/features/installation-policy/domain/effective-capability-policy-reader.ts new file mode 100644 index 00000000..821ec09e --- /dev/null +++ b/apps/mobile/src/features/installation-policy/domain/effective-capability-policy-reader.ts @@ -0,0 +1,30 @@ +import type { + EffectiveCapabilityDecision, + EffectiveCapabilityPolicyReader, + VerifiedPolicyStore, +} from "../capabilities/installation-policy"; + +export function createEffectiveCapabilityPolicyReader(input: { + readonly nowEpochMs: () => number; + readonly store: VerifiedPolicyStore; +}): EffectiveCapabilityPolicyReader { + return { + async read(capabilityId): Promise { + const snapshot = await input.store.read(); + if (snapshot === null) { + return { kind: "disabled", reason: "no-valid-policy" }; + } + if (Date.parse(snapshot.manifest.expiresAt) <= input.nowEpochMs()) { + return { kind: "disabled", reason: "expired" }; + } + if (!snapshot.manifest.capabilities.includes(capabilityId)) { + return { kind: "disabled", reason: "not-allowed" }; + } + return { + kind: "enabled", + sequence: snapshot.manifest.sequence, + verifiedAtEpochMs: snapshot.verifiedAtEpochMs, + }; + }, + }; +} diff --git a/apps/mobile/src/features/installation-policy/tests/effective-capability-policy-reader.test.ts b/apps/mobile/src/features/installation-policy/tests/effective-capability-policy-reader.test.ts new file mode 100644 index 00000000..bc32b185 --- /dev/null +++ b/apps/mobile/src/features/installation-policy/tests/effective-capability-policy-reader.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import type { CapabilityManifest } from "@streamfusion/core/relay"; + +import { createEffectiveCapabilityPolicyReader } from "../domain/effective-capability-policy-reader"; + +const playbackId = "compat.playback.twitch-gql-usher"; + +function manifest( + capabilities: readonly string[], + expiresAt = "2027-09-01T00:00:00.000Z", +): CapabilityManifest { + return { + capabilities, + environment: "development", + expiresAt, + issuedAt: "2026-09-01T00:00:00.000Z", + schemaVersion: 1, + sequence: 4, + }; +} + +describe("effective capability policy reader", () => { + it("disables when no verified snapshot exists", async () => { + const reader = createEffectiveCapabilityPolicyReader({ + nowEpochMs: () => Date.parse("2026-09-14T00:00:00.000Z"), + store: { read: async () => null, write: async () => true }, + }); + await expect(reader.read(playbackId)).resolves.toEqual({ + kind: "disabled", + reason: "no-valid-policy", + }); + }); + + it("disables omitted identifiers and expired snapshots independently", async () => { + const omitted = createEffectiveCapabilityPolicyReader({ + nowEpochMs: () => Date.parse("2026-09-14T00:00:00.000Z"), + store: { + read: async () => ({ + manifest: manifest([]), + verifiedAtEpochMs: 1, + }), + write: async () => true, + }, + }); + await expect(omitted.read(playbackId)).resolves.toEqual({ + kind: "disabled", + reason: "not-allowed", + }); + + const expired = createEffectiveCapabilityPolicyReader({ + nowEpochMs: () => Date.parse("2027-09-02T00:00:00.000Z"), + store: { + read: async () => ({ + manifest: manifest([playbackId], "2027-09-01T00:00:00.000Z"), + verifiedAtEpochMs: 1, + }), + write: async () => true, + }, + }); + await expect(expired.read(playbackId)).resolves.toEqual({ + kind: "disabled", + reason: "expired", + }); + }); + + it("enables an unexpired listed identifier", async () => { + const reader = createEffectiveCapabilityPolicyReader({ + nowEpochMs: () => Date.parse("2026-09-14T00:00:00.000Z"), + store: { + read: async () => ({ + manifest: manifest([playbackId]), + verifiedAtEpochMs: 88, + }), + write: async () => true, + }, + }); + await expect(reader.read(playbackId)).resolves.toEqual({ + kind: "enabled", + sequence: 4, + verifiedAtEpochMs: 88, + }); + }); +}); diff --git a/apps/mobile/src/features/native-contracts/adapters/android-capability-contracts.ts b/apps/mobile/src/features/native-contracts/adapters/android-capability-contracts.ts index 339dcb0d..a0f33202 100644 --- a/apps/mobile/src/features/native-contracts/adapters/android-capability-contracts.ts +++ b/apps/mobile/src/features/native-contracts/adapters/android-capability-contracts.ts @@ -5,6 +5,9 @@ import { import type { AndroidCaptionsContractPort, + NativePlaybackEvent, + NativePlaybackFailureCode, + PlaybackEndState, AndroidCapabilityId, AndroidCapabilityReadiness, AndroidDecoderObservation, @@ -55,7 +58,7 @@ function describe(capability: AndroidCapabilityId): string { function expectedContractVersion(capability: AndroidCapabilityId): 1 | 2 | 3 { if (capability === "diagnostics") return 3; - if (capability === "media-jobs") return 2; + if (capability === "media-jobs" || capability === "playback") return 2; return 1; } @@ -228,6 +231,53 @@ function playbackState(value: unknown): PlaybackSessionState | undefined { : undefined; } +function playbackEndState( + sessionId: string, + value: unknown, +): PlaybackEndState | undefined { + const result = object(value); + if (!result) return undefined; + if (result.kind === "missing") { + const missingId = nonEmptyString(result.sessionId); + return missingId === sessionId ? { kind: "missing", sessionId } : undefined; + } + if (result.kind === "ended") { + const state = playbackState(result.state); + return state?.sessionId === sessionId ? { kind: "ended", state } : undefined; + } + return undefined; +} + +const PLAYBACK_FAILURE_CODES: readonly NativePlaybackFailureCode[] = [ + "PLAYBACK_DECODER_UNSUPPORTED", + "PLAYBACK_NETWORK_FAILED", + "PLAYBACK_SOURCE_REJECTED", + "PLAYBACK_UNKNOWN", +]; + +function nativePlaybackEvent(value: unknown): NativePlaybackEvent | undefined { + const event = object(value); + const sessionId = event ? nonEmptyString(event.sessionId) : undefined; + if (!event || !sessionId) return undefined; + if (event.kind === "buffering") return { kind: "buffering", sessionId }; + if (event.kind === "playing") return { kind: "playing", sessionId }; + if (event.kind === "ended") return { kind: "ended", sessionId }; + if ( + event.kind === "paused" && + (event.reason === "background" || event.reason === "user") + ) { + return { kind: "paused", reason: event.reason, sessionId }; + } + if (event.kind === "failed") { + const code = PLAYBACK_FAILURE_CODES.find((item) => item === event.code); + const detail = nonEmptyString(event.detail); + return code && detail + ? { code, detail, kind: "failed", sessionId } + : undefined; + } + return undefined; +} + function unwrapCompleted(value: unknown): unknown { let current = toPlainJson(value); for (let step = 0; step < 3; step += 1) { @@ -560,11 +610,22 @@ export function createAndroidPlaybackContractPort( "playback", reader, (binding) => binding.endFocusedSession(sessionId), - (value) => { - const state = playbackState(value); - return state?.sessionId === sessionId ? state : undefined; - }, + (value) => playbackEndState(sessionId, value), ), + subscribe(listener) { + const resolution = resolveBinding("playback", reader); + if (resolution.kind === "unavailable" || !resolution.binding.addListener) { + return () => undefined; + } + const subscription = resolution.binding.addListener( + "onNativePlayback", + (event) => { + const parsed = nativePlaybackEvent(event); + if (parsed) listener(parsed); + }, + ); + return () => subscription.remove(); + }, }; } diff --git a/apps/mobile/src/features/native-contracts/adapters/expo-capability-contracts.ts b/apps/mobile/src/features/native-contracts/adapters/expo-capability-contracts.ts index ec961868..af972ca9 100644 --- a/apps/mobile/src/features/native-contracts/adapters/expo-capability-contracts.ts +++ b/apps/mobile/src/features/native-contracts/adapters/expo-capability-contracts.ts @@ -19,6 +19,10 @@ export interface ExpoBindingReader { } export interface ExpoPlaybackBinding { + readonly addListener?: ( + eventName: string, + listener: (event: unknown) => void, + ) => { readonly remove: () => void }; readonly endFocusedSession: (sessionId: string) => Promise; readonly enterPictureInPicture: (sessionId: string) => Promise; readonly getContractVersion: () => number; diff --git a/apps/mobile/src/features/native-contracts/capabilities/android-capability-contracts.ts b/apps/mobile/src/features/native-contracts/capabilities/android-capability-contracts.ts index 04b0596e..998cfcaf 100644 --- a/apps/mobile/src/features/native-contracts/capabilities/android-capability-contracts.ts +++ b/apps/mobile/src/features/native-contracts/capabilities/android-capability-contracts.ts @@ -66,16 +66,49 @@ export interface PlaybackSessionState { readonly sessionId: string; } +export type PlaybackEndState = + | { + readonly kind: "ended"; + readonly state: PlaybackSessionState; + } + | { + readonly kind: "missing"; + readonly sessionId: string; + }; + +export type NativePlaybackFailureCode = + | "PLAYBACK_DECODER_UNSUPPORTED" + | "PLAYBACK_NETWORK_FAILED" + | "PLAYBACK_SOURCE_REJECTED" + | "PLAYBACK_UNKNOWN"; + +export type NativePlaybackEvent = + | { readonly kind: "buffering"; readonly sessionId: string } + | { readonly kind: "playing"; readonly sessionId: string } + | { + readonly kind: "paused"; + readonly reason: "background" | "user"; + readonly sessionId: string; + } + | { readonly kind: "ended"; readonly sessionId: string } + | { + readonly code: NativePlaybackFailureCode; + readonly detail: string; + readonly kind: "failed"; + readonly sessionId: string; + }; + export interface AndroidPlaybackContractPort extends AndroidCapabilityContractPort { endFocusedSession( sessionId: string, - ): Promise>; + ): Promise>; enterPictureInPicture( sessionId: string, ): Promise>; startFocusedSession( request: PlaybackSessionRequest, ): Promise>; + subscribe(listener: (event: NativePlaybackEvent) => void): () => void; } export type MediaJobKind = "download" | "recording"; diff --git a/apps/mobile/src/features/native-contracts/components/native-capability-stub-proof-control.tsx b/apps/mobile/src/features/native-contracts/components/native-capability-stub-proof-control.tsx index fab84392..8f8e9d4a 100644 --- a/apps/mobile/src/features/native-contracts/components/native-capability-stub-proof-control.tsx +++ b/apps/mobile/src/features/native-contracts/components/native-capability-stub-proof-control.tsx @@ -33,9 +33,10 @@ export function NativeCapabilityStubProofControl({ ANDROID CONTRACT CHECKS - Media Jobs is a live contract. Remaining playback, captions, and - maintenance stubs still return unsupported. This check cancels a - nonexistent job without starting work, then reads Diagnostics. + Playback and Media Jobs are live contracts. Remaining captions and + maintenance stubs still return unsupported. This check ends a + nonexistent playback session and cancels a nonexistent job without + starting work, then reads Diagnostics. {detail ? ( , ): string | undefined { + if (capability === "playback") { + return result.kind === "completed" && isMissingSession(result.value, proofId) + ? undefined + : "playback did not end a nonexistent session without starting work."; + } if (capability === "media jobs") { return result.kind === "completed" && isMissingJob(result.value, proofId) ? undefined diff --git a/apps/mobile/src/features/native-contracts/tests/android-capability-contracts.test.ts b/apps/mobile/src/features/native-contracts/tests/android-capability-contracts.test.ts index 77e791be..9896c6e4 100644 --- a/apps/mobile/src/features/native-contracts/tests/android-capability-contracts.test.ts +++ b/apps/mobile/src/features/native-contracts/tests/android-capability-contracts.test.ts @@ -26,7 +26,7 @@ const unsupported = async () => ({ const playbackBinding: ExpoPlaybackBinding = { endFocusedSession: unsupported, enterPictureInPicture: unsupported, - getContractVersion: () => 1, + getContractVersion: () => 2, startFocusedSession: unsupported, }; const fixtureTimestamp = "2026-09-12T00:00:00.000Z"; @@ -156,7 +156,7 @@ describe("Android capability module contracts", () => { { capability: "diagnostics", contractVersion: 3, kind: "ready" }, { capability: "maintenance", contractVersion: 1, kind: "ready" }, { capability: "media-jobs", contractVersion: 2, kind: "ready" }, - { capability: "playback", contractVersion: 1, kind: "ready" }, + { capability: "playback", contractVersion: 2, kind: "ready" }, ]); await expect( contracts.playback.enterPictureInPicture("watch-1"), @@ -347,6 +347,23 @@ describe("Android capability module contracts", () => { kind: "completed", value: { sessionId: "watch-1" }, }); + await expect( + createAndroidPlaybackContractPort( + reader({ + ...playbackBinding, + endFocusedSession: async (sessionId) => ({ + kind: "completed", + value: { kind: "missing", sessionId }, + }), + }), + ).endFocusedSession("streamfusion-contract-proof-nonexistent"), + ).resolves.toEqual({ + kind: "completed", + value: { + kind: "missing", + sessionId: "streamfusion-contract-proof-nonexistent", + }, + }); await expect( mediaJobs.startRecoverableJob({ jobId: "job-1", @@ -522,9 +539,12 @@ describe("Android capability module contracts", () => { playback: createAndroidPlaybackContractPort( reader({ ...playbackBinding, - endFocusedSession: async () => { + endFocusedSession: async (sessionId) => { calls.push("playback.end"); - return unsupported(); + return { + kind: "completed", + value: { kind: "missing", sessionId }, + }; }, enterPictureInPicture: unsafe, startFocusedSession: unsafe, diff --git a/apps/mobile/src/features/shell/components/app-shell.tsx b/apps/mobile/src/features/shell/components/app-shell.tsx index 2bbf4ea6..c8c0f17d 100644 --- a/apps/mobile/src/features/shell/components/app-shell.tsx +++ b/apps/mobile/src/features/shell/components/app-shell.tsx @@ -68,6 +68,9 @@ import { FollowingWorkspace } from "@mobile/features/follows/components/followin import type { ConnectivitySession } from "@mobile/features/connectivity/capabilities/connectivity-session"; import { ConnectivityDiagnosticsPanel } from "@mobile/features/connectivity/components/connectivity-diagnostics-panel"; import { ProxySettingsPanel } from "@mobile/features/connectivity/components/proxy-settings-panel"; +import { WatchRoute } from "@mobile/features/watch/components/watch-route"; +import type { WatchScreenRuntime } from "@mobile/features/watch/components/watch-screen"; +import type { WatchTarget } from "@mobile/features/watch/capabilities/watch"; import { DestinationIcon } from "./destination-icon"; import { resolveHardwareBack } from "../domain/hardware-back"; @@ -138,6 +141,7 @@ export function AppShell({ discoveryPreferences, followingSession, connectivitySession, + watch, }: { readonly activityRepository: ActivityRepository; readonly developmentActivityProof: DevelopmentActivityProofViewModel | null; @@ -179,6 +183,7 @@ export function AppShell({ readonly discoveryPreferences: DiscoveryPreferenceStore; readonly followingSession: FollowingSession; readonly connectivitySession: ConnectivitySession; + readonly watch: WatchScreenRuntime; }) { const activityRepositoryEpoch = developmentActivityProof?.kind === "proof" || @@ -315,6 +320,7 @@ export function AppShell({ discoveryPreferences={discoveryPreferences} followingSession={followingSession} connectivitySession={connectivitySession} + watch={watch} /> @@ -471,6 +477,7 @@ function ShellScreen({ discoveryPreferences, followingSession, connectivitySession, + watch, }: { readonly activity: ReturnType; readonly developmentActivityProof: DevelopmentActivityProofViewModel | null; @@ -512,6 +519,7 @@ function ShellScreen({ readonly discoveryPreferences: DiscoveryPreferenceStore; readonly followingSession: FollowingSession; readonly connectivitySession: ConnectivitySession; + readonly watch: WatchScreenRuntime; }) { const route = getActiveShellRoute(state); const location = getActiveShellLocation(state); @@ -522,6 +530,48 @@ function ShellScreen({ scrollView.current?.scrollTo({ animated: false, y: 0 }); }, [scrollRequest]); + const openWatch = (target: WatchTarget) => { + dispatch({ + type: "navigate", + location: { + route: "watch/session-preview", + target: { + channelId: target.channelId, + channelLogin: target.channelName, + kind: "channel", + platform: target.platform, + }, + }, + }); + }; + + if (location.route === "watch" || location.route === "watch/session-preview") { + const target = + location.route === "watch/session-preview" && + location.target.kind === "channel" + ? { + channelId: location.target.channelId, + channelName: location.target.channelLogin, + platform: location.target.platform, + } + : null; + return ( + + + openWatch({ + channelId: stream.channelId, + channelName: stream.channelName, + platform: stream.platform, + }) + } + screen={watch} + target={target} + /> + + ); + } + if (location.route === "activity") { return ( @@ -668,6 +718,7 @@ function ShellScreen({ diff --git a/apps/mobile/src/features/watch/adapters/android/android-focused-playback.ts b/apps/mobile/src/features/watch/adapters/android/android-focused-playback.ts new file mode 100644 index 00000000..8662ee60 --- /dev/null +++ b/apps/mobile/src/features/watch/adapters/android/android-focused-playback.ts @@ -0,0 +1,54 @@ +import type { + AndroidNativeFailure, + AndroidPlaybackContractPort, +} from "@mobile/features/native-contracts/capabilities/android-capability-contracts"; + +import type { + FocusedPlaybackFailure, + FocusedPlaybackPort, +} from "../../capabilities/watch"; + +export function createAndroidFocusedPlaybackPort( + contract: AndroidPlaybackContractPort, +): FocusedPlaybackPort { + return { + async start(input) { + const result = await contract.startFocusedSession({ + sessionId: input.sessionId, + sourceUri: input.sourceUri, + }); + if (result.kind === "completed") { + return { kind: "started", session: result.value }; + } + return { failure: mapFailure(result.failure), kind: "unavailable" }; + }, + async end(sessionId) { + const result = await contract.endFocusedSession(sessionId); + if (result.kind === "completed") { + return result.value.kind === "missing" + ? { kind: "missing", sessionId } + : { kind: "ended", sessionId }; + } + return { failure: mapFailure(result.failure), kind: "unavailable" }; + }, + subscribe(listener) { + return contract.subscribe(listener); + }, + }; +} + +function mapFailure(failure: AndroidNativeFailure): FocusedPlaybackFailure { + if (failure.code === "NATIVE_BINDING_UNAVAILABLE") { + return { code: "BINDING_UNAVAILABLE", detail: failure.diagnostic }; + } + if (failure.code === "NATIVE_CONTRACT_VERSION_UNSUPPORTED") { + return { code: "CONTRACT_UNSUPPORTED", detail: failure.diagnostic }; + } + if (failure.code === "NATIVE_OPERATION_UNSUPPORTED") { + return { code: "OPERATION_UNSUPPORTED", detail: failure.diagnostic }; + } + if (failure.code === "NATIVE_INVOCATION_FAILED") { + return { code: "INVOCATION_FAILED", detail: failure.diagnostic }; + } + return { code: "RESULT_INVALID", detail: failure.diagnostic }; +} diff --git a/apps/mobile/src/features/watch/adapters/android/android-media3-player-surface.tsx b/apps/mobile/src/features/watch/adapters/android/android-media3-player-surface.tsx new file mode 100644 index 00000000..782eff4f --- /dev/null +++ b/apps/mobile/src/features/watch/adapters/android/android-media3-player-surface.tsx @@ -0,0 +1,46 @@ +import { requireNativeViewManager } from "expo-modules-core"; +import { StyleSheet, View } from "react-native"; + +export type PlayerSurfaceProps = { + readonly sessionId: string; + readonly testID?: string; +}; + +type NativePlaybackViewProps = { + readonly sessionId: string; + readonly style?: object; + readonly testID?: string; +}; + +let NativePlaybackView: + | ReturnType> + | null = null; +try { + NativePlaybackView = + requireNativeViewManager("StreamFusionPlayback"); +} catch { + NativePlaybackView = null; +} + +export function AndroidMedia3PlayerSurface({ + sessionId, + testID, +}: PlayerSurfaceProps) { + const testProps = testID === undefined ? {} : { testID }; + if (!NativePlaybackView) { + return ; + } + return ( + + ); +} + +const styles = StyleSheet.create({ + surface: { + flex: 1, + }, +}); diff --git a/apps/mobile/src/features/watch/adapters/discovery-watch-inspection-reader.ts b/apps/mobile/src/features/watch/adapters/discovery-watch-inspection-reader.ts new file mode 100644 index 00000000..d7787123 --- /dev/null +++ b/apps/mobile/src/features/watch/adapters/discovery-watch-inspection-reader.ts @@ -0,0 +1,110 @@ +import type { + ChannelPageOutcome, + DiscoverySession, + PlatformReadOutcome, +} from "@mobile/features/discovery/capabilities/platform-reads"; +import type { Stream } from "@streamfusion/core/content"; + +import type { + WatchContextFailure, + WatchInfo, + WatchInspection, + WatchInspectionReader, + WatchRelated, + WatchTarget, +} from "../capabilities/watch"; + +export function createDiscoveryWatchInspectionReader( + session: DiscoverySession, +): WatchInspectionReader { + return { + async read({ signal, target }): Promise { + const page = await session.readChannel({ + channel: { + id: target.channelId, + platform: target.platform, + username: target.channelName, + }, + signal, + }); + const info = infoFrom(page); + return { + info, + related: await relatedFrom(session, signal, target, page), + target, + }; + }, + }; +} + +function infoFrom(page: ChannelPageOutcome): WatchInfo { + if (page.channel && page.live && page.live.isLive) { + return { channel: page.channel, kind: "live", stream: page.live }; + } + if (page.channel) { + return { channel: page.channel, kind: "ended" }; + } + return { failure: failureFrom(page), kind: "unavailable" }; +} + +async function relatedFrom( + session: DiscoverySession, + signal: AbortSignal, + target: WatchTarget, + page: ChannelPageOutcome, +): Promise { + const categoryId = page.live?.categoryId ?? page.channel?.categoryId; + if (!categoryId) return { kind: "empty" }; + const outcome = await session.readCategoryStreams({ + categoryId, + platform: target.platform, + signal, + }); + if (outcome.path.kind === "unavailable" && outcome.path.reason === "cancelled") { + return { failure: { kind: "cancelled" }, kind: "unavailable" }; + } + if (outcome.status === "failed") { + return { failure: failureFromOutcome(outcome), kind: "unavailable" }; + } + const items = outcome.items.filter( + (stream) => + stream.channelId !== target.channelId && + stream.channelName.toLowerCase() !== target.channelName.toLowerCase(), + ); + return items.length === 0 ? { kind: "empty" } : { items, kind: "ready" }; +} + +function failureFrom(page: ChannelPageOutcome): WatchContextFailure { + if (page.path.kind === "unavailable" && page.path.reason === "cancelled") { + return { kind: "cancelled" }; + } + if (page.path.kind === "unavailable" && page.path.reason === "offline") { + return { + detail: "Channel details are unavailable while offline.", + kind: "offline", + retry: "manual", + }; + } + return { + detail: "Channel details are unavailable.", + kind: "provider-unavailable", + retry: "manual", + }; +} + +function failureFromOutcome( + outcome: PlatformReadOutcome, +): WatchContextFailure { + if (outcome.path.kind === "unavailable" && outcome.path.reason === "offline") { + return { + detail: "Related streams are unavailable while offline.", + kind: "offline", + retry: "manual", + }; + } + return { + detail: "Related streams are unavailable.", + kind: "provider-unavailable", + retry: "manual", + }; +} diff --git a/apps/mobile/src/features/watch/adapters/expo-watch-provider-fallback.ts b/apps/mobile/src/features/watch/adapters/expo-watch-provider-fallback.ts new file mode 100644 index 00000000..78511c07 --- /dev/null +++ b/apps/mobile/src/features/watch/adapters/expo-watch-provider-fallback.ts @@ -0,0 +1,27 @@ +import * as Linking from "expo-linking"; + +import type { + WatchProviderPagePort, + WatchTarget, +} from "../capabilities/watch"; + +export function createExpoWatchProviderFallback(): WatchProviderPagePort { + return { + async open(target: WatchTarget) { + const path = encodeURIComponent(target.channelName); + const url = + target.platform === "twitch" + ? `https://www.twitch.tv/${path}` + : `https://kick.com/${path}`; + try { + await Linking.openURL(url); + return { kind: "opened" as const }; + } catch { + return { + detail: "Could not open the provider page.", + kind: "unavailable" as const, + }; + } + }, + }; +} diff --git a/apps/mobile/src/features/watch/adapters/focused-playback-protection.ts b/apps/mobile/src/features/watch/adapters/focused-playback-protection.ts new file mode 100644 index 00000000..565e3b18 --- /dev/null +++ b/apps/mobile/src/features/watch/adapters/focused-playback-protection.ts @@ -0,0 +1,26 @@ +import type { + FocusedPlaybackProtection, + FocusedPlaybackProtectionPort, +} from "../capabilities/watch"; + +export function createMemoryPlaybackProtection(): FocusedPlaybackProtectionPort { + const held = new Set(); + const listeners = new Set<() => void>(); + return { + acquire(sessionId) { + held.add(sessionId); + return { + release() { + held.delete(sessionId); + }, + }; + }, + snapshot(): FocusedPlaybackProtection { + return { kind: "normal" }; + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/apps/mobile/src/features/watch/adapters/kick/kick-live-playback-source.ts b/apps/mobile/src/features/watch/adapters/kick/kick-live-playback-source.ts new file mode 100644 index 00000000..c6a23bb5 --- /dev/null +++ b/apps/mobile/src/features/watch/adapters/kick/kick-live-playback-source.ts @@ -0,0 +1,121 @@ +import type { + LivePlaybackSourceResolution, + LivePlaybackSourceResolver, +} from "../../capabilities/watch"; +import { asHlsSourceUri } from "../../domain/hls-source"; + +export function createKickLivePlaybackSource(input: { + readonly fetch: typeof globalThis.fetch; +}): LivePlaybackSourceResolver<"kick"> { + return { + integration: "kick-v1-playback-url", + platform: "kick", + async resolve({ signal, target }): Promise { + const slug = encodeURIComponent(target.channelName.toLowerCase()); + try { + const response = await input.fetch( + `https://kick.com/api/v1/channels/${slug}`, + { + headers: { Accept: "application/json" }, + method: "GET", + signal, + }, + ); + if (response.status === 401 || response.status === 403) { + return rejected(response.status); + } + if (response.status === 404) { + return { + failure: { + detail: "This Kick channel is not live.", + kind: "channel-offline", + }, + integration: "kick-v1-playback-url", + kind: "unavailable", + }; + } + if (!response.ok) return rejected(response.status); + const payload: unknown = await response.json(); + if (!isLive(payload)) { + return { + failure: { + detail: "This Kick channel is not live.", + kind: "channel-offline", + }, + integration: "kick-v1-playback-url", + kind: "unavailable", + }; + } + const sourceUri = asHlsSourceUri(playbackUrl(payload) ?? ""); + if (!sourceUri) { + return { + failure: { + detail: "Kick returned an unusable live source.", + kind: "invalid-response", + }, + integration: "kick-v1-playback-url", + kind: "unavailable", + }; + } + return { + integration: "kick-v1-playback-url", + kind: "resolved", + sourceUri, + }; + } catch (error) { + if (isAbort(error)) { + return { + failure: { kind: "cancelled" }, + integration: "kick-v1-playback-url", + kind: "unavailable", + }; + } + return { + failure: { + detail: "Could not reach Kick for live playback.", + kind: "offline", + }, + integration: "kick-v1-playback-url", + kind: "unavailable", + }; + } + }, + }; +} + +function isLive(payload: unknown): boolean { + if (!isRecord(payload) || !isRecord(payload.livestream)) return false; + return payload.livestream.is_live === true; +} + +function playbackUrl(payload: unknown): string | undefined { + if (!isRecord(payload)) return undefined; + if (typeof payload.playback_url === "string") return payload.playback_url; + if (isRecord(payload.livestream) && typeof payload.livestream.source === "string") { + return payload.livestream.source; + } + return undefined; +} + +function rejected(status: number): LivePlaybackSourceResolution { + return { + failure: { + detail: "Kick rejected the guest playback request.", + kind: "provider-rejected", + status, + }, + integration: "kick-v1-playback-url", + kind: "unavailable", + }; +} + +function isAbort(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError") + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/mobile/src/features/watch/adapters/playback-compatibility-policy.ts b/apps/mobile/src/features/watch/adapters/playback-compatibility-policy.ts new file mode 100644 index 00000000..360ed940 --- /dev/null +++ b/apps/mobile/src/features/watch/adapters/playback-compatibility-policy.ts @@ -0,0 +1,27 @@ +import type { EffectiveCapabilityPolicyReader } from "@mobile/features/installation-policy/capabilities/installation-policy"; +import type { Platform } from "@streamfusion/core/platform"; + +import type { + PlaybackCompatibilityDecision, + PlaybackCompatibilityPolicy, +} from "../capabilities/watch"; +import { PLAYBACK_COMPATIBILITY_CAPABILITY } from "../capabilities/watch"; + +export function createPlaybackCompatibilityPolicy( + reader: EffectiveCapabilityPolicyReader, +): PlaybackCompatibilityPolicy { + return { + async read(platform: Platform): Promise { + const decision = await reader.read( + PLAYBACK_COMPATIBILITY_CAPABILITY[platform], + ); + if (decision.kind === "enabled") { + return { kind: "enabled", sequence: decision.sequence }; + } + if (decision.reason === "no-valid-policy") { + return { kind: "enabled", sequence: 0 }; + } + return decision; + }, + }; +} diff --git a/apps/mobile/src/features/watch/adapters/twitch/twitch-live-playback-source.ts b/apps/mobile/src/features/watch/adapters/twitch/twitch-live-playback-source.ts new file mode 100644 index 00000000..5e96e067 --- /dev/null +++ b/apps/mobile/src/features/watch/adapters/twitch/twitch-live-playback-source.ts @@ -0,0 +1,160 @@ +import type { + LivePlaybackSourceResolution, + LivePlaybackSourceResolver, + WatchTarget, +} from "../../capabilities/watch"; +import { asHlsSourceUri } from "../../domain/hls-source"; + +const TWITCH_GQL_URL = "https://gql.twitch.tv/gql"; +const TWITCH_CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp"; +const PLAYBACK_ACCESS_TOKEN_HASH = + "ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9"; + +export function createTwitchLivePlaybackSource(input: { + readonly fetch: typeof globalThis.fetch; +}): LivePlaybackSourceResolver<"twitch"> { + return { + integration: "twitch-gql-usher", + platform: "twitch", + async resolve({ signal, target }): Promise { + try { + const response = await input.fetch(TWITCH_GQL_URL, { + body: JSON.stringify(playbackAccessTokenBody(target)), + headers: { + "Client-Id": TWITCH_CLIENT_ID, + "Content-Type": "application/json", + }, + method: "POST", + signal, + }); + if (response.status === 401 || response.status === 403) { + return rejected(response.status); + } + if (!response.ok) { + return rejected(response.status); + } + const payload: unknown = await response.json(); + const token = streamToken(payload); + if (token === null) { + return { + failure: { + detail: "This Twitch channel is not live.", + kind: "channel-offline", + }, + integration: "twitch-gql-usher", + kind: "unavailable", + }; + } + const sourceUri = asHlsSourceUri(usherUrl(target.channelName, token)); + if (!sourceUri) { + return invalid(); + } + return { + integration: "twitch-gql-usher", + kind: "resolved", + sourceUri, + }; + } catch (error) { + return failureFrom(error); + } + }, + }; +} + +function playbackAccessTokenBody(target: WatchTarget) { + return { + extensions: { + persistedQuery: { + sha256Hash: PLAYBACK_ACCESS_TOKEN_HASH, + version: 1, + }, + }, + operationName: "PlaybackAccessToken", + variables: { + isLive: true, + isVod: false, + login: target.channelName, + platform: "web", + playerType: "site", + vodID: "", + }, + }; +} + +function streamToken(payload: unknown): { signature: string; value: string } | null { + if (!isRecord(payload) || !isRecord(payload.data)) return null; + const token = payload.data.streamPlaybackAccessToken; + if (!isRecord(token)) return null; + const signature = token.signature; + const value = token.value; + if (typeof signature !== "string" || signature.length === 0) return null; + if (typeof value !== "string" || value.length === 0) return null; + return { signature, value }; +} + +function usherUrl( + channelName: string, + token: { signature: string; value: string }, +): string { + const channel = encodeURIComponent(channelName.toLowerCase()); + const params = new URLSearchParams({ + allow_audio_only: "true", + allow_source: "true", + p: String(Math.floor(Math.random() * 999999)), + sig: token.signature, + token: token.value, + }); + return `https://usher.ttvnw.net/api/channel/hls/${channel}.m3u8?${params.toString()}`; +} + +function rejected(status: number): LivePlaybackSourceResolution { + return { + failure: { + detail: "Twitch rejected the guest playback request.", + kind: "provider-rejected", + status, + }, + integration: "twitch-gql-usher", + kind: "unavailable", + }; +} + +function invalid(): LivePlaybackSourceResolution { + return { + failure: { + detail: "Twitch returned an unusable live source.", + kind: "invalid-response", + }, + integration: "twitch-gql-usher", + kind: "unavailable", + }; +} + +function failureFrom(error: unknown): LivePlaybackSourceResolution { + if (isAbort(error)) { + return { + failure: { kind: "cancelled" }, + integration: "twitch-gql-usher", + kind: "unavailable", + }; + } + return { + failure: { + detail: "Could not reach Twitch for live playback.", + kind: "offline", + }, + integration: "twitch-gql-usher", + kind: "unavailable", + }; +} + +function isAbort(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError") + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/mobile/src/features/watch/capabilities/watch.ts b/apps/mobile/src/features/watch/capabilities/watch.ts new file mode 100644 index 00000000..4f0b1473 --- /dev/null +++ b/apps/mobile/src/features/watch/capabilities/watch.ts @@ -0,0 +1,296 @@ +import type { + Channel, + Stream, +} from "@streamfusion/core/content"; +import type { Platform, StreamChannelIdentity } from "@streamfusion/core/platform"; + +export type WatchTarget = StreamChannelIdentity; + +export type WatchTab = "chat" | "info" | "related"; + +export type WatchChatAvailability = { + readonly detail: string; + readonly kind: "not-connected"; +}; + +export type WatchContextFailure = + | { readonly kind: "cancelled" } + | { + readonly detail: string; + readonly kind: "offline"; + readonly retry: "manual"; + } + | { + readonly detail: string; + readonly kind: "provider-unavailable"; + readonly retry: "manual"; + }; + +export type WatchInfo = + | { + readonly channel: Channel; + readonly kind: "live"; + readonly stream: Stream; + } + | { + readonly channel: Channel; + readonly kind: "ended"; + } + | { + readonly failure: WatchContextFailure; + readonly kind: "unavailable"; + }; + +export type WatchRelated = + | { + readonly items: readonly Stream[]; + readonly kind: "ready"; + } + | { readonly kind: "empty" } + | { + readonly failure: WatchContextFailure; + readonly kind: "unavailable"; + }; + +export type WatchInspection = { + readonly info: WatchInfo; + readonly related: WatchRelated; + readonly target: WatchTarget; +}; + +export interface WatchInspectionReader { + read(input: { + readonly signal: AbortSignal; + readonly target: WatchTarget; + }): Promise; +} + +export const PLAYBACK_COMPATIBILITY_CAPABILITY = { + kick: "compat.playback.kick-v1", + twitch: "compat.playback.twitch-gql-usher", +} as const satisfies Readonly>; + +export type PlaybackIntegration = "kick-v1-playback-url" | "twitch-gql-usher"; + +export type PlaybackCompatibilityDecision = + | { readonly kind: "enabled"; readonly sequence: number } + | { + readonly kind: "disabled"; + readonly reason: "expired" | "no-valid-policy" | "not-allowed"; + }; + +export interface PlaybackCompatibilityPolicy { + read(platform: Platform): Promise; +} + +declare const hlsSourceUri: unique symbol; + +export type HlsSourceUri = string & { + readonly [hlsSourceUri]: "validated-https-hls"; +}; + +export type LivePlaybackSourceFailure = + | { readonly kind: "cancelled" } + | { readonly detail: string; readonly kind: "offline" } + | { readonly detail: string; readonly kind: "channel-offline" } + | { + readonly detail: string; + readonly kind: "provider-rejected"; + readonly status: number; + } + | { readonly detail: string; readonly kind: "invalid-response" }; + +export type LivePlaybackSourceResolution = + | { + readonly integration: PlaybackIntegration; + readonly kind: "resolved"; + readonly sourceUri: HlsSourceUri; + } + | { + readonly failure: LivePlaybackSourceFailure; + readonly integration: PlaybackIntegration; + readonly kind: "unavailable"; + }; + +export interface LivePlaybackSourceResolver< + TPlatform extends Platform = Platform, +> { + readonly integration: PlaybackIntegration; + readonly platform: TPlatform; + resolve(input: { + readonly signal: AbortSignal; + readonly target: WatchTarget & { readonly platform: TPlatform }; + }): Promise; +} + +export type LivePlaybackSources = { + readonly [TPlatform in Platform]: LivePlaybackSourceResolver; +}; + +export type NativePlaybackFailureCode = + | "PLAYBACK_DECODER_UNSUPPORTED" + | "PLAYBACK_NETWORK_FAILED" + | "PLAYBACK_SOURCE_REJECTED" + | "PLAYBACK_UNKNOWN"; + +export type NativePlaybackEvent = + | { readonly kind: "buffering"; readonly sessionId: string } + | { readonly kind: "playing"; readonly sessionId: string } + | { + readonly kind: "paused"; + readonly reason: "background" | "user"; + readonly sessionId: string; + } + | { readonly kind: "ended"; readonly sessionId: string } + | { + readonly code: NativePlaybackFailureCode; + readonly detail: string; + readonly kind: "failed"; + readonly sessionId: string; + }; + +export type PlaybackSessionState = { + readonly pictureInPictureEligible: boolean; + readonly sessionId: string; +}; + +export type FocusedPlaybackFailure = { + readonly code: + | "BINDING_UNAVAILABLE" + | "CONTRACT_UNSUPPORTED" + | "INVOCATION_FAILED" + | "OPERATION_UNSUPPORTED" + | "RESULT_INVALID"; + readonly detail: string; +}; + +export type FocusedPlaybackStartResult = + | { readonly kind: "started"; readonly session: PlaybackSessionState } + | { readonly failure: FocusedPlaybackFailure; readonly kind: "unavailable" }; + +export type FocusedPlaybackEndResult = + | { readonly kind: "ended"; readonly sessionId: string } + | { readonly kind: "missing"; readonly sessionId: string } + | { readonly failure: FocusedPlaybackFailure; readonly kind: "unavailable" }; + +export interface FocusedPlaybackPort { + end(sessionId: string): Promise; + start(input: { + readonly sessionId: string; + readonly sourceUri: HlsSourceUri; + }): Promise; + subscribe(listener: (event: NativePlaybackEvent) => void): () => void; +} + +export type FocusedPlaybackProtection = + | { readonly kind: "normal" } + | { + readonly detail: string; + readonly kind: "degraded"; + readonly recoveryCondition: string; + readonly stage: 1 | 2 | 3 | 4 | 5; + }; + +export interface FocusedPlaybackProtectionLease { + release(): void; +} + +export interface FocusedPlaybackProtectionPort { + acquire(sessionId: string): FocusedPlaybackProtectionLease; + snapshot(): FocusedPlaybackProtection; + subscribe(listener: () => void): () => void; +} + +export type PlaybackPhase = "buffering" | "paused" | "playing"; + +export type WatchRecovery = "open-provider" | "refresh-policy" | "retry"; + +export type WatchPlaybackFailure = + | { + readonly integration: PlaybackIntegration; + readonly kind: "compatibility-disabled"; + readonly lastSuccessfulStage: "none"; + readonly platform: Platform; + readonly reason: "expired" | "no-valid-policy" | "not-allowed"; + readonly recovery: readonly WatchRecovery[]; + } + | { + readonly code: Exclude; + readonly detail: string; + readonly integration: PlaybackIntegration; + readonly kind: "source-unavailable"; + readonly lastSuccessfulStage: "policy-authorized"; + readonly platform: Platform; + readonly recovery: readonly WatchRecovery[]; + } + | { + readonly detail: string; + readonly integration: PlaybackIntegration; + readonly kind: "native-unavailable"; + readonly lastSuccessfulStage: "source-resolved"; + readonly platform: Platform; + readonly recovery: readonly WatchRecovery[]; + } + | { + readonly code: NativePlaybackFailureCode; + readonly detail: string; + readonly integration: PlaybackIntegration; + readonly kind: "playback-failed"; + readonly lastSuccessfulStage: "native-session-started"; + readonly platform: Platform; + readonly recovery: readonly WatchRecovery[]; + }; + +export type FocusedWatchState = + | { readonly kind: "ready"; readonly target: WatchTarget } + | { readonly kind: "resolving"; readonly target: WatchTarget } + | { + readonly integration: PlaybackIntegration; + readonly kind: "active"; + readonly phase: PlaybackPhase; + readonly policySequence: number; + readonly protection: FocusedPlaybackProtection; + readonly session: PlaybackSessionState; + readonly target: WatchTarget; + } + | { + readonly integration: PlaybackIntegration; + readonly kind: "ended"; + readonly sessionId: string; + readonly target: WatchTarget; + } + | { + readonly failure: WatchPlaybackFailure; + readonly kind: "failed"; + readonly target: WatchTarget; + }; + +export type WatchStartResult = + | { readonly kind: "started"; readonly session: PlaybackSessionState } + | { readonly kind: "cancelled" } + | { readonly failure: WatchPlaybackFailure; readonly kind: "failed" }; + +export interface FocusedWatchSession { + dispose(): Promise; + leave(target: WatchTarget): Promise; + snapshot(target: WatchTarget): FocusedWatchState; + start(target: WatchTarget): Promise; + subscribe(listener: () => void): () => void; +} + +export type WatchProviderFallbackResult = + | { readonly kind: "opened" } + | { readonly detail: string; readonly kind: "unavailable" }; + +export interface WatchProviderPagePort { + open(target: WatchTarget): Promise; +} + +export interface WatchSessionIdSource { + create(): string; +} + +export interface WatchRuntime { + readonly inspection: WatchInspectionReader; + readonly session: FocusedWatchSession; +} diff --git a/apps/mobile/src/features/watch/components/use-focused-watch-session.ts b/apps/mobile/src/features/watch/components/use-focused-watch-session.ts new file mode 100644 index 00000000..bbd0bcbe --- /dev/null +++ b/apps/mobile/src/features/watch/components/use-focused-watch-session.ts @@ -0,0 +1,18 @@ +import { useSyncExternalStore } from "react"; + +import type { + FocusedWatchSession, + FocusedWatchState, + WatchTarget, +} from "../capabilities/watch"; + +export function useFocusedWatchSession( + session: FocusedWatchSession, + target: WatchTarget, +): FocusedWatchState { + return useSyncExternalStore( + session.subscribe, + () => session.snapshot(target), + () => session.snapshot(target), + ); +} diff --git a/apps/mobile/src/features/watch/components/watch-route.tsx b/apps/mobile/src/features/watch/components/watch-route.tsx new file mode 100644 index 00000000..d004bdd9 --- /dev/null +++ b/apps/mobile/src/features/watch/components/watch-route.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { Stream } from "@streamfusion/core/content"; + +import type { WatchTab, WatchTarget } from "../capabilities/watch"; +import { useFocusedWatchSession } from "./use-focused-watch-session"; +import { WatchEmptyState, WatchScreen, type WatchScreenRuntime } from "./watch-screen"; + +const chat = { + detail: "Chat is not connected in this build. Watching continues.", + kind: "not-connected" as const, +}; + +export function WatchRoute({ + onOpenRelated, + screen, + target, +}: { + readonly onOpenRelated: (stream: Stream) => void; + readonly screen: WatchScreenRuntime; + readonly target: WatchTarget | null; +}) { + if (!target) return ; + return ( + + ); +} + +function WatchSessionRoute({ + onOpenRelated, + screen, + target, +}: { + readonly onOpenRelated: (stream: Stream) => void; + readonly screen: WatchScreenRuntime; + readonly target: WatchTarget; +}) { + const [tab, setTab] = useState("info"); + const playback = useFocusedWatchSession(screen.runtime.session, target); + const inspection = useQuery({ + queryFn: ({ signal }) => screen.runtime.inspection.read({ signal, target }), + queryKey: ["watch-inspection", target.platform, target.channelId, target.channelName], + }); + useEffect( + () => () => { + void screen.runtime.session.leave(target); + }, + [screen.runtime.session, target], + ); + return ( + { + void screen.openProviderPage.open(target); + }} + onOpenRelated={onOpenRelated} + onRetry={() => { + void screen.runtime.session.start(target); + }} + onSelectTab={setTab} + onStart={() => { + void screen.runtime.session.start(target); + }} + playback={playback} + tab={tab} + target={target} + /> + ); +} diff --git a/apps/mobile/src/features/watch/components/watch-screen.tsx b/apps/mobile/src/features/watch/components/watch-screen.tsx new file mode 100644 index 00000000..c784d27c --- /dev/null +++ b/apps/mobile/src/features/watch/components/watch-screen.tsx @@ -0,0 +1,197 @@ +import { Pressable, StyleSheet, Text, View } from "react-native"; +import type { ComponentType } from "react"; +import type { Stream } from "@streamfusion/core/content"; + +import { + mobileColors, + mobileRadii, + mobileSizing, + mobileSpacing, +} from "@mobile/design/tokens"; +import type { + FocusedWatchState, + WatchChatAvailability, + WatchInspection, + WatchRuntime, + WatchTab, + WatchTarget, +} from "../capabilities/watch"; +import { composeWatchView } from "../domain/watch-view"; +import { WatchTabs } from "./watch-tabs"; + +export type PlayerSurfaceProps = { + readonly sessionId: string; + readonly testID?: string; +}; + +export type WatchScreenRuntime = { + readonly openProviderPage: { + open(target: WatchTarget): Promise; + }; + readonly PlayerSurface: ComponentType; + readonly runtime: WatchRuntime; +}; + +export function WatchScreen({ + PlayerSurface, + chat, + inspection, + onOpenProviderPage, + onOpenRelated, + onRetry, + onSelectTab, + onStart, + playback, + tab, + target, +}: { + readonly PlayerSurface: ComponentType; + readonly chat: WatchChatAvailability; + readonly inspection: WatchInspection | null; + readonly onOpenProviderPage: () => void; + readonly onOpenRelated: (stream: Stream) => void; + readonly onRetry: () => void; + readonly onSelectTab: (tab: WatchTab) => void; + readonly onStart: () => void; + readonly playback: FocusedWatchState; + readonly tab: WatchTab; + readonly target: WatchTarget; +}) { + const view = composeWatchView(playback); + return ( + + + {view.showPlayer && view.sessionId ? ( + + ) : ( + + + {view.title} + + + {view.detail} + + + )} + + + {`${target.platform.toUpperCase()} · ${target.channelName}`} + + {view.primaryAction === "start" ? ( + + ) : null} + {view.primaryAction === "retry" ? ( + + ) : null} + {showsProvider(playback) ? ( + + ) : null} + + + ); +} + +function showsProvider(playback: FocusedWatchState): boolean { + if (playback.kind === "ended") return true; + if (playback.kind !== "failed") return false; + return playback.failure.recovery.includes("open-provider"); +} + +function Action({ + label, + onPress, + testID, +}: { + readonly label: string; + readonly onPress: () => void; + readonly testID: string; +}) { + return ( + + + {label} + + + ); +} + +export function WatchEmptyState() { + return ( + + + Watch + + + Select a live stream to watch. + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + gap: mobileSpacing.medium, + padding: mobileSpacing.medium, + }, + playerStage: { + aspectRatio: 16 / 9, + backgroundColor: mobileColors.surfaceMuted, + borderRadius: mobileRadii.medium, + overflow: "hidden", + width: "100%", + }, + placeholder: { + flex: 1, + gap: mobileSpacing.xSmall, + justifyContent: "center", + padding: mobileSpacing.medium, + }, + title: { + color: mobileColors.textPrimary, + fontSize: 18, + fontWeight: "700", + lineHeight: 24, + }, + body: { + color: mobileColors.textSecondary, + fontSize: 14, + fontWeight: "500", + lineHeight: 20, + }, + meta: { + color: mobileColors.textCategory, + fontSize: 12, + fontWeight: "600", + lineHeight: 16, + }, + action: { + alignItems: "center", + backgroundColor: mobileColors.textPrimary, + borderRadius: mobileRadii.medium, + justifyContent: "center", + minHeight: mobileSizing.minimumTouchTarget, + }, + actionLabel: { + color: mobileColors.background, + fontSize: 14, + fontWeight: "700", + lineHeight: 20, + }, +}); diff --git a/apps/mobile/src/features/watch/components/watch-tabs.tsx b/apps/mobile/src/features/watch/components/watch-tabs.tsx new file mode 100644 index 00000000..81c70d01 --- /dev/null +++ b/apps/mobile/src/features/watch/components/watch-tabs.tsx @@ -0,0 +1,231 @@ +import { Pressable, StyleSheet, Text, View } from "react-native"; +import type { Stream } from "@streamfusion/core/content"; + +import { + mobileColors, + mobileRadii, + mobileSizing, + mobileSpacing, +} from "@mobile/design/tokens"; +import type { + WatchChatAvailability, + WatchInfo, + WatchRelated, + WatchTab, +} from "../capabilities/watch"; + +export function WatchTabs({ + chat, + info, + onOpenRelated, + onSelect, + related, + tab, +}: { + readonly chat: WatchChatAvailability; + readonly info: WatchInfo | null; + readonly onOpenRelated: (stream: Stream) => void; + readonly onSelect: (tab: WatchTab) => void; + readonly related: WatchRelated | null; + readonly tab: WatchTab; +}) { + return ( + + + onSelect("info")} /> + onSelect("related")} /> + onSelect("chat")} /> + + {tab === "chat" ? : null} + {tab === "info" ? : null} + {tab === "related" ? ( + + ) : null} + + ); +} + +function TabButton({ + active, + label, + onPress, +}: { + readonly active: boolean; + readonly label: string; + readonly onPress: () => void; +}) { + return ( + + + {label} + + + ); +} + +function ChatPane({ chat }: { readonly chat: WatchChatAvailability }) { + return ( + + + {chat.detail} + + + ); +} + +function InfoPane({ info }: { readonly info: WatchInfo | null }) { + if (!info) { + return ( + + + Loading channel details. + + + ); + } + if (info.kind === "unavailable") { + return ( + + + {info.failure.kind === "cancelled" + ? "Channel details were cancelled." + : info.failure.detail} + + + ); + } + if (info.kind === "ended") { + return ( + + + {info.channel.displayName} + + + This channel is not live. + + + ); + } + return ( + + + {info.stream.title} + + + {`${info.channel.displayName} · ${info.stream.viewerCount} viewers`} + + + ); +} + +function RelatedPane({ + onOpenRelated, + related, +}: { + readonly onOpenRelated: (stream: Stream) => void; + readonly related: WatchRelated | null; +}) { + if (!related) { + return ( + + + Loading related streams. + + + ); + } + if (related.kind === "empty") { + return ( + + + No related live streams. + + + ); + } + if (related.kind === "unavailable") { + return ( + + + {related.failure.kind === "cancelled" + ? "Related streams were cancelled." + : related.failure.detail} + + + ); + } + return ( + + {related.items.map((stream) => ( + onOpenRelated(stream)} + style={styles.relatedRow} + testID={`watch-related-${stream.channelId}`} + > + + {stream.channelDisplayName} + + + {stream.title} + + + ))} + + ); +} + +const styles = StyleSheet.create({ + region: { + gap: mobileSpacing.small, + }, + tabs: { + flexDirection: "row", + gap: mobileSpacing.xSmall, + }, + tab: { + alignItems: "center", + backgroundColor: mobileColors.surfaceRaised, + borderRadius: mobileRadii.medium, + justifyContent: "center", + minHeight: mobileSizing.minimumTouchTarget, + paddingHorizontal: mobileSpacing.medium, + }, + tabActive: { + backgroundColor: mobileColors.textPrimary, + }, + tabLabel: { + color: mobileColors.background, + fontSize: 13, + fontWeight: "700", + lineHeight: 18, + }, + pane: { + gap: mobileSpacing.xSmall, + }, + title: { + color: mobileColors.textPrimary, + fontSize: 16, + fontWeight: "700", + lineHeight: 22, + }, + body: { + color: mobileColors.textSecondary, + fontSize: 14, + fontWeight: "500", + lineHeight: 20, + }, + relatedRow: { + backgroundColor: mobileColors.surfaceRaised, + borderRadius: mobileRadii.medium, + minHeight: mobileSizing.minimumTouchTarget, + padding: mobileSpacing.medium, + }, +}); diff --git a/apps/mobile/src/features/watch/composition/guest-watch-screen.ts b/apps/mobile/src/features/watch/composition/guest-watch-screen.ts new file mode 100644 index 00000000..ff4aee57 --- /dev/null +++ b/apps/mobile/src/features/watch/composition/guest-watch-screen.ts @@ -0,0 +1,46 @@ +import type { DiscoverySession } from "@mobile/features/discovery/capabilities/platform-reads"; +import { createEffectiveCapabilityPolicyReader } from "@mobile/features/installation-policy/domain/effective-capability-policy-reader"; +import type { VerifiedPolicyStore } from "@mobile/features/installation-policy/capabilities/installation-policy"; +import type { AndroidPlaybackContractPort } from "@mobile/features/native-contracts/capabilities/android-capability-contracts"; + +import { createAndroidFocusedPlaybackPort } from "../adapters/android/android-focused-playback"; +import { AndroidMedia3PlayerSurface } from "../adapters/android/android-media3-player-surface"; +import { createDiscoveryWatchInspectionReader } from "../adapters/discovery-watch-inspection-reader"; +import { createMemoryPlaybackProtection } from "../adapters/focused-playback-protection"; +import { createKickLivePlaybackSource } from "../adapters/kick/kick-live-playback-source"; +import { createPlaybackCompatibilityPolicy } from "../adapters/playback-compatibility-policy"; +import { createTwitchLivePlaybackSource } from "../adapters/twitch/twitch-live-playback-source"; +import { createExpoWatchProviderFallback } from "../adapters/expo-watch-provider-fallback"; +import type { WatchScreenRuntime } from "../components/watch-screen"; +import type { WatchSessionIdSource } from "../capabilities/watch"; +import { createWatchRuntime } from "./watch-runtime"; + +export function createGuestWatchScreen(input: { + readonly discovery: DiscoverySession; + readonly fetch: typeof globalThis.fetch; + readonly nowEpochMs?: () => number; + readonly playback: AndroidPlaybackContractPort; + readonly policyStore: VerifiedPolicyStore; + readonly sessionIds: WatchSessionIdSource; +}): WatchScreenRuntime { + return { + openProviderPage: createExpoWatchProviderFallback(), + PlayerSurface: AndroidMedia3PlayerSurface, + runtime: createWatchRuntime({ + inspection: createDiscoveryWatchInspectionReader(input.discovery), + playback: createAndroidFocusedPlaybackPort(input.playback), + policy: createPlaybackCompatibilityPolicy( + createEffectiveCapabilityPolicyReader({ + nowEpochMs: input.nowEpochMs ?? Date.now, + store: input.policyStore, + }), + ), + protection: createMemoryPlaybackProtection(), + sessionIds: input.sessionIds, + sources: { + kick: createKickLivePlaybackSource({ fetch: input.fetch }), + twitch: createTwitchLivePlaybackSource({ fetch: input.fetch }), + }, + }), + }; +} diff --git a/apps/mobile/src/features/watch/composition/watch-runtime.ts b/apps/mobile/src/features/watch/composition/watch-runtime.ts new file mode 100644 index 00000000..6bef5513 --- /dev/null +++ b/apps/mobile/src/features/watch/composition/watch-runtime.ts @@ -0,0 +1,24 @@ +import type { + FocusedPlaybackPort, + FocusedPlaybackProtectionPort, + LivePlaybackSources, + PlaybackCompatibilityPolicy, + WatchInspectionReader, + WatchRuntime, + WatchSessionIdSource, +} from "../capabilities/watch"; +import { createFocusedWatchSession } from "../domain/focused-watch-session"; + +export function createWatchRuntime(input: { + readonly inspection: WatchInspectionReader; + readonly playback: FocusedPlaybackPort; + readonly policy: PlaybackCompatibilityPolicy; + readonly protection: FocusedPlaybackProtectionPort; + readonly sessionIds: WatchSessionIdSource; + readonly sources: LivePlaybackSources; +}): WatchRuntime { + return { + inspection: input.inspection, + session: createFocusedWatchSession(input), + }; +} diff --git a/apps/mobile/src/features/watch/domain/focused-watch-session.ts b/apps/mobile/src/features/watch/domain/focused-watch-session.ts new file mode 100644 index 00000000..afeed706 --- /dev/null +++ b/apps/mobile/src/features/watch/domain/focused-watch-session.ts @@ -0,0 +1,272 @@ +import { streamsMatchChannelIdentity } from "@streamfusion/core/platform"; + +import type { + FocusedPlaybackPort, + FocusedPlaybackProtection, + FocusedPlaybackProtectionPort, + FocusedWatchSession, + FocusedWatchState, + LivePlaybackSourceResolution, + LivePlaybackSources, + NativePlaybackEvent, + PlaybackCompatibilityPolicy, + PlaybackIntegration, + PlaybackPhase, + PlaybackSessionState, + WatchPlaybackFailure, + WatchSessionIdSource, + WatchStartResult, + WatchTarget, +} from "../capabilities/watch"; + +export function createFocusedWatchSession(input: { + readonly playback: FocusedPlaybackPort; + readonly policy: PlaybackCompatibilityPolicy; + readonly protection: FocusedPlaybackProtectionPort; + readonly sessionIds: WatchSessionIdSource; + readonly sources: LivePlaybackSources; +}): FocusedWatchSession { + const listeners = new Set<() => void>(); + let generation = 0; + let current: CurrentSession | null = null; + const notify = () => listeners.forEach((listener) => listener()); + const unsubscribeNative = input.playback.subscribe((event) => { + applyNativeEvent(event); + }); + const unsubscribeProtection = input.protection.subscribe(() => { + if (current?.kind === "active") { + current = { ...current, protection: input.protection.snapshot() }; + notify(); + } + }); + + function applyNativeEvent(event: NativePlaybackEvent): void { + if (current?.kind !== "active" || current.session.sessionId !== event.sessionId) { + return; + } + if (event.kind === "ended") { + current.lease.release(); + current = { + integration: current.integration, + kind: "ended", + sessionId: event.sessionId, + target: current.target, + }; + notify(); + return; + } + if (event.kind === "failed") { + current.lease.release(); + current = { + failure: { + code: event.code, + detail: event.detail, + integration: current.integration, + kind: "playback-failed", + lastSuccessfulStage: "native-session-started", + platform: current.target.platform, + recovery: ["retry", "open-provider"], + }, + kind: "failed", + target: current.target, + }; + notify(); + return; + } + current = { ...current, phase: phaseFrom(event) }; + notify(); + } + + async function abandon(sessionId: string): Promise { + const result = await input.playback.end(sessionId); + if (result.kind === "unavailable") return; + } + + return { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + snapshot(target) { + if (current && streamsMatchChannelIdentity(current.target, target)) { + return toState(current); + } + return { kind: "ready", target }; + }, + async start(target): Promise { + const attempt = ++generation; + const previous = current; + current = { kind: "resolving", target }; + notify(); + if (previous?.kind === "active") { + previous.lease.release(); + await abandon(previous.session.sessionId); + } + if (attempt !== generation) return { kind: "cancelled" }; + const integration: PlaybackIntegration = + target.platform === "twitch" ? "twitch-gql-usher" : "kick-v1-playback-url"; + const controller = new AbortController(); + const policy = await input.policy.read(target.platform); + if (attempt !== generation) { + controller.abort(); + return { kind: "cancelled" }; + } + if (policy.kind === "disabled") { + const failed: WatchStartResult = { + failure: { + integration, + kind: "compatibility-disabled", + lastSuccessfulStage: "none", + platform: target.platform, + reason: policy.reason, + recovery: ["refresh-policy", "open-provider"], + }, + kind: "failed", + }; + current = { failure: failed.failure, kind: "failed", target }; + notify(); + return failed; + } + const resolved = await resolveSource(input.sources, target, controller.signal); + if (attempt !== generation) return { kind: "cancelled" }; + if (resolved.kind === "unavailable") { + if (resolved.failure.kind === "cancelled") return { kind: "cancelled" }; + const failed: WatchStartResult = { + failure: { + code: resolved.failure.kind, + detail: resolved.failure.detail, + integration, + kind: "source-unavailable", + lastSuccessfulStage: "policy-authorized", + platform: target.platform, + recovery: ["retry", "open-provider"], + }, + kind: "failed", + }; + current = { failure: failed.failure, kind: "failed", target }; + notify(); + return failed; + } + const sessionId = input.sessionIds.create(); + const started = await input.playback.start({ + sessionId, + sourceUri: resolved.sourceUri, + }); + if (attempt !== generation) { + void abandon(sessionId); + return { kind: "cancelled" }; + } + if (started.kind === "unavailable") { + const failed: WatchStartResult = { + failure: { + detail: started.failure.detail, + integration, + kind: "native-unavailable", + lastSuccessfulStage: "source-resolved", + platform: target.platform, + recovery: ["retry", "open-provider"], + }, + kind: "failed", + }; + current = { failure: failed.failure, kind: "failed", target }; + notify(); + return failed; + } + current = { + integration, + kind: "active", + lease: input.protection.acquire(started.session.sessionId), + phase: "buffering", + policySequence: policy.sequence, + protection: input.protection.snapshot(), + session: started.session, + target, + }; + notify(); + return { kind: "started", session: started.session }; + }, + async leave(target) { + if (!current || !streamsMatchChannelIdentity(current.target, target)) { + return; + } + generation += 1; + if (current.kind === "active") { + const sessionId = current.session.sessionId; + current.lease.release(); + current = { kind: "ready", target }; + notify(); + await abandon(sessionId); + return; + } + current = { kind: "ready", target }; + notify(); + }, + async dispose() { + generation += 1; + unsubscribeNative(); + unsubscribeProtection(); + listeners.clear(); + if (current?.kind === "active") { + current.lease.release(); + await abandon(current.session.sessionId); + } + current = null; + }, + }; +} + +type CurrentSession = + | { readonly kind: "ready"; readonly target: WatchTarget } + | { readonly kind: "resolving"; readonly target: WatchTarget } + | { + readonly integration: PlaybackIntegration; + readonly kind: "active"; + readonly lease: { release(): void }; + readonly phase: PlaybackPhase; + readonly policySequence: number; + readonly protection: FocusedPlaybackProtection; + readonly session: PlaybackSessionState; + readonly target: WatchTarget; + } + | { + readonly integration: PlaybackIntegration; + readonly kind: "ended"; + readonly sessionId: string; + readonly target: WatchTarget; + } + | { + readonly failure: WatchPlaybackFailure; + readonly kind: "failed"; + readonly target: WatchTarget; + }; + +function toState(current: CurrentSession): FocusedWatchState { + if (current.kind === "active") { + const { lease: _lease, ...state } = current; + return state; + } + return current; +} + +async function resolveSource( + sources: LivePlaybackSources, + target: WatchTarget, + signal: AbortSignal, +): Promise { + if (target.platform === "kick") { + return sources.kick.resolve({ + signal, + target: { ...target, platform: "kick" }, + }); + } + return sources.twitch.resolve({ + signal, + target: { ...target, platform: "twitch" }, + }); +} + +function phaseFrom(event: NativePlaybackEvent): PlaybackPhase { + if (event.kind === "paused") return "paused"; + if (event.kind === "playing") return "playing"; + return "buffering"; +} diff --git a/apps/mobile/src/features/watch/domain/hls-source.ts b/apps/mobile/src/features/watch/domain/hls-source.ts new file mode 100644 index 00000000..079eef60 --- /dev/null +++ b/apps/mobile/src/features/watch/domain/hls-source.ts @@ -0,0 +1,12 @@ +import type { HlsSourceUri } from "../capabilities/watch"; + +export function asHlsSourceUri(value: string): HlsSourceUri | undefined { + try { + const url = new URL(value); + if (url.protocol !== "https:") return undefined; + if (!url.pathname.toLowerCase().includes(".m3u8")) return undefined; + return value as HlsSourceUri; + } catch { + return undefined; + } +} diff --git a/apps/mobile/src/features/watch/domain/watch-view.ts b/apps/mobile/src/features/watch/domain/watch-view.ts new file mode 100644 index 00000000..370e04b2 --- /dev/null +++ b/apps/mobile/src/features/watch/domain/watch-view.ts @@ -0,0 +1,141 @@ +import { streamsMatchChannelIdentity } from "@streamfusion/core/platform"; + +import type { + FocusedWatchState, + WatchPlaybackFailure, + WatchRecovery, + WatchTarget, +} from "../capabilities/watch"; + +export type WatchPrimaryAction = "start" | "retry" | "none"; + +export type WatchView = { + readonly detail: string; + readonly primaryAction: WatchPrimaryAction; + readonly recovery: readonly WatchRecovery[]; + readonly sessionId: string | null; + readonly showPlayer: boolean; + readonly title: string; +}; + +export function composeWatchView(state: FocusedWatchState): WatchView { + if (state.kind === "ready") { + return view("Watch", "Start watching this live stream.", "start", null, false, []); + } + if (state.kind === "resolving") { + return view( + "Starting Watch", + "Resolving a live source for this stream.", + "none", + null, + false, + [], + ); + } + if (state.kind === "active") { + return view( + phaseTitle(state.phase), + protectionDetail(state), + "none", + state.session.sessionId, + true, + [], + ); + } + if (state.kind === "ended") { + return view( + "Stream ended", + "This live stream is no longer playing.", + "retry", + null, + false, + ["retry", "open-provider"], + ); + } + return failureView(state.failure); +} + +export function sameWatchTarget( + first: WatchTarget, + second: WatchTarget, +): boolean { + return streamsMatchChannelIdentity(first, second); +} + +function failureView(failure: WatchPlaybackFailure): WatchView { + if (failure.kind === "compatibility-disabled") { + return view( + "Watch unavailable", + "Live playback is disabled by the current capability policy.", + "retry", + null, + false, + failure.recovery, + ); + } + if (failure.kind === "source-unavailable") { + return view( + sourceTitle(failure.code), + failure.detail, + "retry", + null, + false, + failure.recovery, + ); + } + if (failure.kind === "native-unavailable") { + return view( + "Player unavailable", + failure.detail, + "retry", + null, + false, + failure.recovery, + ); + } + return view( + "Playback stopped", + failure.detail, + "retry", + null, + false, + failure.recovery, + ); +} + +function sourceTitle( + code: Extract["code"], +): string { + if (code === "channel-offline") return "Channel is offline"; + if (code === "offline") return "Network unavailable"; + if (code === "provider-rejected") return "Provider rejected playback"; + return "Live source unavailable"; +} + +function phaseTitle(phase: "buffering" | "paused" | "playing"): string { + if (phase === "buffering") return "Buffering"; + if (phase === "paused") return "Paused"; + return "Playing"; +} + +function protectionDetail( + state: Extract, +): string { + if (state.protection.kind === "normal") { + return state.phase === "buffering" + ? "The player is buffering this live stream." + : "Focused live playback is active."; + } + return state.protection.detail; +} + +function view( + title: string, + detail: string, + primaryAction: WatchPrimaryAction, + sessionId: string | null, + showPlayer: boolean, + recovery: readonly WatchRecovery[], +): WatchView { + return { detail, primaryAction, recovery, sessionId, showPlayer, title }; +} diff --git a/apps/mobile/src/features/watch/tests/discovery-watch-inspection-reader.test.ts b/apps/mobile/src/features/watch/tests/discovery-watch-inspection-reader.test.ts new file mode 100644 index 00000000..fa78892e --- /dev/null +++ b/apps/mobile/src/features/watch/tests/discovery-watch-inspection-reader.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import type { DiscoverySession } from "@mobile/features/discovery/capabilities/platform-reads"; +import { fixtureChannel, fixtureChannelPage } from "@mobile/features/discovery/domain/channel-fixture"; +import { fixtureStream } from "@mobile/features/discovery/domain/discovery-fixture"; + +import { createDiscoveryWatchInspectionReader } from "../adapters/discovery-watch-inspection-reader"; + +describe("discovery watch inspection reader", () => { + it("keeps Info when Related is empty", async () => { + const live = fixtureStream("twitch", "twitch-ready", 40); + const session = { + async readChannel() { + return { + ...fixtureChannelPage("twitch", "ready"), + live: { ...live, categoryId: undefined }, + }; + }, + async readCategoryStreams() { + return { + cache: { kind: "miss" as const }, + items: [], + path: { kind: "relay" as const, platform: "twitch" as const }, + platform: "twitch" as const, + status: "complete" as const, + }; + }, + } as unknown as DiscoverySession; + const reader = createDiscoveryWatchInspectionReader(session); + const inspection = await reader.read({ + signal: new AbortController().signal, + target: { + channelId: live.channelId, + channelName: live.channelName, + platform: "twitch", + }, + }); + expect(inspection.info.kind).toBe("live"); + expect(inspection.related.kind).toBe("empty"); + expect(fixtureChannel("twitch", true).displayName).toBe("Twitch Live"); + }); +}); diff --git a/apps/mobile/src/features/watch/tests/focused-watch-session.test.ts b/apps/mobile/src/features/watch/tests/focused-watch-session.test.ts new file mode 100644 index 00000000..e696f880 --- /dev/null +++ b/apps/mobile/src/features/watch/tests/focused-watch-session.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFocusedWatchSession } from "../domain/focused-watch-session"; +import type { + FocusedPlaybackPort, + FocusedPlaybackProtectionPort, + LivePlaybackSources, + PlaybackCompatibilityPolicy, + WatchTarget, +} from "../capabilities/watch"; +import { asHlsSourceUri } from "../domain/hls-source"; + +const target: WatchTarget = { + channelId: "twitch-1", + channelName: "live", + platform: "twitch", +}; + +const sourceUri = asHlsSourceUri("https://usher.ttvnw.net/api/channel/hls/live.m3u8")!; + +function protection(): FocusedPlaybackProtectionPort { + return { + acquire: () => ({ release() {} }), + snapshot: () => ({ kind: "normal" }), + subscribe: () => () => undefined, + }; +} + +function sources( + resolve: LivePlaybackSources["twitch"]["resolve"] = async () => ({ + integration: "twitch-gql-usher", + kind: "resolved", + sourceUri, + }), +): LivePlaybackSources { + return { + kick: { + integration: "kick-v1-playback-url", + platform: "kick", + resolve: async () => ({ + failure: { detail: "unused", kind: "channel-offline" }, + integration: "kick-v1-playback-url", + kind: "unavailable", + }), + }, + twitch: { + integration: "twitch-gql-usher", + platform: "twitch", + resolve, + }, + }; +} + +function playbackPort( + overrides: Partial = {}, +): FocusedPlaybackPort & { ended: string[] } { + const ended: string[] = []; + return { + ended, + async start({ sessionId }) { + return { + kind: "started", + session: { pictureInPictureEligible: false, sessionId }, + }; + }, + async end(sessionId) { + ended.push(sessionId); + return { kind: "missing", sessionId }; + }, + subscribe: () => () => undefined, + ...overrides, + }; +} + +describe("focused watch session", () => { + it("fails closed when a signed policy omits the integration", async () => { + const policy: PlaybackCompatibilityPolicy = { + read: async () => ({ kind: "disabled", reason: "not-allowed" }), + }; + const playback = playbackPort(); + const session = createFocusedWatchSession({ + playback, + policy, + protection: protection(), + sessionIds: { create: () => "watch:1" }, + sources: sources(), + }); + const result = await session.start(target); + expect(result).toMatchObject({ + failure: { kind: "compatibility-disabled", reason: "not-allowed" }, + kind: "failed", + }); + expect(session.snapshot(target).kind).toBe("failed"); + }); + + it("starts a native session after policy and source resolve", async () => { + const session = createFocusedWatchSession({ + playback: playbackPort(), + policy: { read: async () => ({ kind: "enabled", sequence: 2 }) }, + protection: protection(), + sessionIds: { create: () => "watch:1" }, + sources: sources(), + }); + await expect(session.start(target)).resolves.toMatchObject({ + kind: "started", + session: { sessionId: "watch:1" }, + }); + expect(session.snapshot(target)).toMatchObject({ + kind: "active", + session: { pictureInPictureEligible: false, sessionId: "watch:1" }, + }); + }); + + it("does not let a stale end stop a newer session", async () => { + const playback = playbackPort(); + const session = createFocusedWatchSession({ + playback, + policy: { read: async () => ({ kind: "enabled", sequence: 1 }) }, + protection: protection(), + sessionIds: { + create: vi + .fn() + .mockReturnValueOnce("watch:a") + .mockReturnValueOnce("watch:b"), + }, + sources: sources(), + }); + await session.start(target); + const other: WatchTarget = { + channelId: "twitch-2", + channelName: "other", + platform: "twitch", + }; + await session.start(other); + await session.leave(target); + expect(session.snapshot(other).kind).toBe("active"); + expect(playback.ended).toContain("watch:a"); + expect(playback.ended).not.toContain("watch:b"); + }); +}); diff --git a/apps/mobile/src/features/watch/tests/kick-live-playback-source.test.ts b/apps/mobile/src/features/watch/tests/kick-live-playback-source.test.ts new file mode 100644 index 00000000..1b774e34 --- /dev/null +++ b/apps/mobile/src/features/watch/tests/kick-live-playback-source.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { createKickLivePlaybackSource } from "../adapters/kick/kick-live-playback-source"; + +const target = { + channelId: "1", + channelName: "xqc", + platform: "kick" as const, +}; + +describe("kick live playback source", () => { + it("requires livestream.is_live before accepting playback_url", async () => { + const source = createKickLivePlaybackSource({ + fetch: async () => + new Response( + JSON.stringify({ + livestream: { is_live: false }, + playback_url: "https://kick.com/live.m3u8", + }), + { status: 200 }, + ), + }); + await expect( + source.resolve({ signal: new AbortController().signal, target }), + ).resolves.toMatchObject({ + failure: { kind: "channel-offline" }, + kind: "unavailable", + }); + }); + + it("resolves HTTPS HLS from a live payload", async () => { + const source = createKickLivePlaybackSource({ + fetch: async () => + new Response( + JSON.stringify({ + livestream: { is_live: true }, + playback_url: "https://playback.kick.com/live.m3u8", + }), + { status: 200 }, + ), + }); + await expect( + source.resolve({ signal: new AbortController().signal, target }), + ).resolves.toMatchObject({ + kind: "resolved", + sourceUri: "https://playback.kick.com/live.m3u8", + }); + }); +}); diff --git a/apps/mobile/src/features/watch/tests/playback-compatibility-policy.test.ts b/apps/mobile/src/features/watch/tests/playback-compatibility-policy.test.ts new file mode 100644 index 00000000..ecd0dcee --- /dev/null +++ b/apps/mobile/src/features/watch/tests/playback-compatibility-policy.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { createPlaybackCompatibilityPolicy } from "../adapters/playback-compatibility-policy"; + +describe("playback compatibility policy", () => { + it("enables guest playback when no signed policy exists yet", async () => { + const policy = createPlaybackCompatibilityPolicy({ + read: async () => ({ kind: "disabled", reason: "no-valid-policy" }), + }); + await expect(policy.read("twitch")).resolves.toEqual({ + kind: "enabled", + sequence: 0, + }); + }); + + it("disables an omitted identifier in a valid snapshot", async () => { + const policy = createPlaybackCompatibilityPolicy({ + read: async () => ({ kind: "disabled", reason: "not-allowed" }), + }); + await expect(policy.read("kick")).resolves.toEqual({ + kind: "disabled", + reason: "not-allowed", + }); + }); +}); diff --git a/apps/mobile/src/features/watch/tests/twitch-live-playback-source.test.ts b/apps/mobile/src/features/watch/tests/twitch-live-playback-source.test.ts new file mode 100644 index 00000000..0dce3a25 --- /dev/null +++ b/apps/mobile/src/features/watch/tests/twitch-live-playback-source.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { createTwitchLivePlaybackSource } from "../adapters/twitch/twitch-live-playback-source"; + +const target = { + channelId: "1", + channelName: "ninja", + platform: "twitch" as const, +}; + +describe("twitch live playback source", () => { + it("builds an HTTPS Usher URL from a guest token", async () => { + const source = createTwitchLivePlaybackSource({ + fetch: async () => + new Response( + JSON.stringify({ + data: { + streamPlaybackAccessToken: { + signature: "sig", + value: '{"authorization":{"forbidden":false}}', + }, + }, + }), + { status: 200 }, + ), + }); + const result = await source.resolve({ + signal: new AbortController().signal, + target, + }); + expect(result.kind).toBe("resolved"); + if (result.kind !== "resolved") return; + expect(result.sourceUri).toContain( + "https://usher.ttvnw.net/api/channel/hls/ninja.m3u8", + ); + expect(result.sourceUri).toContain("sig=sig"); + }); + + it("treats a missing token as channel-offline", async () => { + const source = createTwitchLivePlaybackSource({ + fetch: async () => + new Response(JSON.stringify({ data: { streamPlaybackAccessToken: null } }), { + status: 200, + }), + }); + await expect( + source.resolve({ signal: new AbortController().signal, target }), + ).resolves.toMatchObject({ + failure: { kind: "channel-offline" }, + kind: "unavailable", + }); + }); +}); diff --git a/apps/mobile/src/features/watch/tests/watch-screen.test.ts b/apps/mobile/src/features/watch/tests/watch-screen.test.ts new file mode 100644 index 00000000..fda637fa --- /dev/null +++ b/apps/mobile/src/features/watch/tests/watch-screen.test.ts @@ -0,0 +1,71 @@ +import { isValidElement, type ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { WatchScreen } from "../components/watch-screen"; +import type { WatchTarget } from "../capabilities/watch"; + +vi.mock("react-native", () => ({ + Pressable: "Pressable", + StyleSheet: { create: (styles: unknown) => styles }, + Text: "Text", + View: "View", +})); + +type ElementProps = Readonly<{ + children?: unknown; + onPress?: () => void; + testID?: string; +}>; +type Element = ReactElement; + +function descendants(node: unknown): readonly Element[] { + if (Array.isArray(node)) return node.flatMap((child) => descendants(child)); + if (!isValidElement(node)) return []; + const element: Element = node; + const candidate = element.type as unknown; + const component = + typeof candidate === "function" + ? (candidate as (props: ElementProps) => unknown) + : null; + if (component) return [element, ...descendants(component(element.props))]; + const children = element.props.children; + const childNodes = Array.isArray(children) ? children : [children]; + return [element, ...childNodes.flatMap((child) => descendants(child))]; +} + +const target: WatchTarget = { + channelId: "twitch-1", + channelName: "live", + platform: "twitch", +}; + +describe("watch screen", () => { + it("requires an explicit start and keeps chat disconnected", () => { + const root = WatchScreen({ + PlayerSurface: () => null, + chat: { + detail: "Chat is not connected in this build. Watching continues.", + kind: "not-connected", + }, + inspection: null, + onOpenProviderPage: () => undefined, + onOpenRelated: () => undefined, + onRetry: () => undefined, + onSelectTab: () => undefined, + onStart: () => undefined, + playback: { kind: "ready", target }, + tab: "chat", + target, + }); + const nodes = descendants(root); + expect(nodes.some((node) => node.props.testID === "watch-start")).toBe(true); + expect(nodes.some((node) => node.props.testID === "watch-player")).toBe( + false, + ); + expect( + nodes.some((node) => + String(node.props.children).includes("Chat is not connected"), + ), + ).toBe(true); + }); +}); diff --git a/apps/mobile/tests/coverage-matrix.test.mjs b/apps/mobile/tests/coverage-matrix.test.mjs index 38e11996..51c30500 100644 --- a/apps/mobile/tests/coverage-matrix.test.mjs +++ b/apps/mobile/tests/coverage-matrix.test.mjs @@ -44,9 +44,9 @@ test("coverage matrix reconciles prototype, contract, and shell routes", () => { assert.equal(ledger.schemaVersion, 1); assert.equal(ledger.issue, 195); assert.equal(ledger.totals.discovered, 185); - assert.equal(ledger.totals.implemented, 29); + assert.equal(ledger.totals.implemented, 32); assert.equal(ledger.totals.partial, 14); - assert.equal(ledger.totals.placeholder, 12); + assert.equal(ledger.totals.placeholder, 9); assert.equal(ledger.totals.missing, 130); assert.equal(ledger.gaps.length, 5); assert.ok(ledger.gaps.some((gap) => gap.id === "GAP-195-01")); @@ -79,4 +79,16 @@ test("coverage matrix reconciles prototype, contract, and shell routes", () => { entry.id === "action:channel-follow" && entry.status === "implemented", ), ); + assert.ok( + ledger.entries.some( + (entry) => entry.id === "screen:watch" && entry.status === "implemented", + ), + ); + assert.ok( + ledger.entries.some( + (entry) => + entry.id === "shell-route:watch/session-preview" && + entry.status === "implemented", + ), + ); }); diff --git a/apps/mobile/tests/native-capability-module-scaffold.test.mjs b/apps/mobile/tests/native-capability-module-scaffold.test.mjs index f2222b64..e070e3be 100644 --- a/apps/mobile/tests/native-capability-module-scaffold.test.mjs +++ b/apps/mobile/tests/native-capability-module-scaffold.test.mjs @@ -121,7 +121,7 @@ test("the Expo module keeps contained stubs plus measured diagnostics and connec source, className === "Diagnostics" ? /Function\("getContractVersion"\) \{ 3 \}/u - : className === "MediaJobs" + : className === "MediaJobs" || className === "Playback" ? /Function\("getContractVersion"\) \{ 2 \}/u : /Function\("getContractVersion"\) \{ 1 \}/u, ); @@ -135,6 +135,11 @@ test("the Expo module keeps contained stubs plus measured diagnostics and connec assert.match(source, /StatFs/u); } else if (className === "MediaJobs") { assert.doesNotMatch(source, /NATIVE_OPERATION_UNSUPPORTED/u); + } else if (className === "Playback") { + assert.match(source, /FocusedPlaybackSessionOwner/u); + assert.match(source, /enterPictureInPicture/u); + assert.match(source, /"NATIVE_OPERATION_UNSUPPORTED"/u); + assert.doesNotMatch(source, /AsyncFunction\("startFocusedSession"\) \{ _: Map/u); } else if (className === "Connectivity") { assert.match(source, /repeat\(2\)/u); assert.match(source, /Proxy\.Type\.HTTP/u); diff --git a/apps/mobile/tests/scaffold.test.mjs b/apps/mobile/tests/scaffold.test.mjs index 58d043e9..7dfef646 100644 --- a/apps/mobile/tests/scaffold.test.mjs +++ b/apps/mobile/tests/scaffold.test.mjs @@ -68,6 +68,10 @@ test("the Android build enables SQLCipher and excludes all app data from backup" assert.equal(packageManifest.dependencies["expo-secure-store"], "57.0.2"); assert.equal(packageManifest.dependencies["expo-sqlite"], "57.0.2"); assert.equal(packageManifest.dependencies["expo-network"], "57.0.1"); + assert.ok( + !appManifest.expo.plugins.flat().includes("expo-network"), + "expo-network has no app.plugin.js; listing it in plugins loads build/Network.js under Node and fails type stripping", + ); }); test("SQLCipher is proven in memory before a persistent database is opened", () => { diff --git a/docs/research/streamfusion-mobile/coverage-matrix-check.mjs b/docs/research/streamfusion-mobile/coverage-matrix-check.mjs index 7b11b447..7ac2a4ab 100644 --- a/docs/research/streamfusion-mobile/coverage-matrix-check.mjs +++ b/docs/research/streamfusion-mobile/coverage-matrix-check.mjs @@ -131,6 +131,7 @@ const implemented = new Set([ "screen:category-detail", "screen:following", "screen:channel", + "screen:watch", "shell-route:search", "shell-route:following", "shell-route:following/manage", @@ -138,6 +139,8 @@ const implemented = new Set([ "shell-route:more/channel", "shell-route:more/categories", "shell-route:more/category-detail", + "shell-route:watch", + "shell-route:watch/session-preview", "action:channel-follow", ]); @@ -159,15 +162,12 @@ const partial = new Set([ ]); const placeholder = new Set([ - "screen:watch", "screen:multi", "screen:history", "screen:moderation-home", "screen:settings", "shell-route:search/result-preview", "shell-route:following/channel-preview", - "shell-route:watch", - "shell-route:watch/session-preview", "shell-route:more/multistream", "shell-route:more/history", "shell-route:more/moderation", @@ -250,6 +250,15 @@ const paths = { "action:channel-follow": [ "apps/mobile/src/features/discovery/components/channel-header.tsx", ], + "screen:watch": [ + "apps/mobile/src/features/watch/components/watch-screen.tsx", + ], + "shell-route:watch": [ + "apps/mobile/src/features/watch/components/watch-route.tsx", + ], + "shell-route:watch/session-preview": [ + "apps/mobile/src/features/watch/components/watch-route.tsx", + ], "shell-route:more/categories": [ "apps/mobile/src/features/discovery/components/categories-screen.tsx", ], @@ -326,7 +335,7 @@ const gaps = [ id: "GAP-195-02", status: "owned-elsewhere", finding: - "Watch, History, and Moderation remain placeholders. Settings still lacks the remaining panels after proxy.", + "Watch is implemented for guest live HLS. History and Moderation remain placeholders. Settings still lacks the remaining panels after proxy.", owners: [147, 148, 149, 150, 152, 155, 159, 167], }, { diff --git a/docs/research/streamfusion-mobile/coverage-matrix-ledger.json b/docs/research/streamfusion-mobile/coverage-matrix-ledger.json index 1b573b88..09d70b22 100644 --- a/docs/research/streamfusion-mobile/coverage-matrix-ledger.json +++ b/docs/research/streamfusion-mobile/coverage-matrix-ledger.json @@ -8,9 +8,9 @@ ], "totals": { "discovered": 185, - "implemented": 29, + "implemented": 32, "partial": 14, - "placeholder": 12, + "placeholder": 9, "missing": 130 }, "entries": [ @@ -131,7 +131,7 @@ "id": "screen:watch", "kind": "screen", "name": "watch", - "status": "placeholder", + "status": "implemented", "owners": [ 152, 153, @@ -139,8 +139,10 @@ 157, 167 ], - "implementation": [], - "verification": "missing", + "implementation": [ + "apps/mobile/src/features/watch/components/watch-screen.tsx" + ], + "verification": "tests", "evidence": [], "designRef": "docs/research/streamfusion-mobile/mobile-screen-and-control-contract.md" }, @@ -1997,10 +1999,12 @@ "id": "shell-route:watch", "kind": "shell-route", "name": "watch", - "status": "placeholder", + "status": "implemented", "owners": [], - "implementation": [], - "verification": "missing", + "implementation": [ + "apps/mobile/src/features/watch/components/watch-route.tsx" + ], + "verification": "tests", "evidence": [], "designRef": "docs/research/streamfusion-mobile/mobile-screen-and-control-contract.md" }, @@ -2008,10 +2012,12 @@ "id": "shell-route:watch/session-preview", "kind": "shell-route", "name": "watch/session-preview", - "status": "placeholder", + "status": "implemented", "owners": [], - "implementation": [], - "verification": "missing", + "implementation": [ + "apps/mobile/src/features/watch/components/watch-route.tsx" + ], + "verification": "tests", "evidence": [], "designRef": "docs/research/streamfusion-mobile/mobile-screen-and-control-contract.md" }, @@ -2201,7 +2207,7 @@ { "id": "GAP-195-02", "status": "owned-elsewhere", - "finding": "Watch, History, and Moderation remain placeholders. Settings still lacks the remaining panels after proxy.", + "finding": "Watch is implemented for guest live HLS. History and Moderation remain placeholders. Settings still lacks the remaining panels after proxy.", "owners": [ 147, 148, diff --git a/docs/research/streamfusion-mobile/coverage-matrix.md b/docs/research/streamfusion-mobile/coverage-matrix.md index c20b4046..75d7b523 100644 --- a/docs/research/streamfusion-mobile/coverage-matrix.md +++ b/docs/research/streamfusion-mobile/coverage-matrix.md @@ -9,9 +9,9 @@ Source revisions are the files in this commit. The checker fails when a contract | Status | Count | | --- | ---: | -| Implemented | 29 | +| Implemented | 32 | | Partial | 14 | -| Placeholder | 12 | +| Placeholder | 9 | | Missing | 130 | | Discovered | 185 | @@ -39,7 +39,7 @@ Missing means no route or control exists yet. | `screen:category-detail` | implemented | #130, #132, #133, #147, #150 | apps/mobile/src/features/discovery/components/category-detail-screen.tsx | tests | | `screen:following` | implemented | #134, #147, #151 | apps/mobile/src/features/follows/components/following-workspace.tsx | tests | | `screen:channel` | implemented | #130, #132, #133, #147, #148, #151 | apps/mobile/src/features/discovery/components/channel-detail-screen.tsx | tests | -| `screen:watch` | placeholder | #152, #153, #156, #157, #167 | — | missing | +| `screen:watch` | implemented | #152, #153, #156, #157, #167 | apps/mobile/src/features/watch/components/watch-screen.tsx | tests | | `screen:video` | missing | #133, #153, #154 | — | missing | | `screen:multi` | placeholder | #143, #160, #167 | — | missing | | `screen:history` | placeholder | #155 | — | missing | @@ -202,8 +202,8 @@ Missing means no route or control exists yet. | `shell-route:following` | implemented | — | apps/mobile/src/features/follows/components/following-workspace.tsx | tests | | `shell-route:following/channel-preview` | placeholder | — | — | missing | | `shell-route:following/manage` | implemented | — | apps/mobile/src/features/follows/components/following-workspace.tsx | tests | -| `shell-route:watch` | placeholder | — | — | missing | -| `shell-route:watch/session-preview` | placeholder | — | — | missing | +| `shell-route:watch` | implemented | — | apps/mobile/src/features/watch/components/watch-route.tsx | tests | +| `shell-route:watch/session-preview` | implemented | — | apps/mobile/src/features/watch/components/watch-route.tsx | tests | | `shell-route:activity` | implemented | — | apps/mobile/src/features/activity/components/activity-screen.tsx | tests | | `shell-route:activity/alert-preview` | implemented | — | — | tests | | `shell-route:activity/job-preview` | implemented | — | apps/mobile/src/features/media-jobs/components/media-job-screen.tsx | tests | @@ -224,7 +224,7 @@ Missing means no route or control exists yet. | Id | Status | Finding | Owners | | --- | --- | --- | --- | | `GAP-195-01` | escalated | More destination order conflicts. The contract lists Accounts before Settings and Diagnostics. SHELL MORE_ROUTE_IDS keeps Accounts last. This PR does not change navigation order. | #104, #139, #195 | -| `GAP-195-02` | owned-elsewhere | Watch, History, and Moderation remain placeholders. Settings still lacks the remaining panels after proxy. | #147, #148, #149, #150, #152, #155, #159, #167 | +| `GAP-195-02` | owned-elsewhere | Watch is implemented for guest live HLS. History and Moderation remain placeholders. Settings still lacks the remaining panels after proxy. | #147, #148, #149, #150, #152, #155, #159, #167 | | `GAP-195-03` | owned-elsewhere | Sixteen Settings panels and six Diagnostics tabs still lack dedicated Mobile routes. Proxy is on Settings and Diagnostics. | #143, #167, #170, #171 | | `GAP-195-04` | owned-elsewhere | Guest and account notification delivery, FCM, and job producers are absent. Activity is a local inbox only. | #151, #163, #172, #173, #174 | | `GAP-195-05` | open | More order is recorded, not changed. Physical-device and live-provider evidence remain missing for unfinished features. | #195, #196 |