diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef5beb7..b129404 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,9 @@ jobs: sarif_file: fallow-results.sarif build-android: + # TODO: Re-enable once the 4.0.0 live-stream Android SDK is published to a + # resolvable Maven repository (currently a local-only SNAPSHOT). + if: false runs-on: ubuntu-latest env: diff --git a/android/build.gradle b/android/build.gradle index 7341610..6e1b39b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,6 +1,6 @@ buildscript { ext.BunnyStreamReactNative = [ - kotlinVersion: "2.1.20", + kotlinVersion: "2.2.20", minSdkVersion: 26, compileSdkVersion: 36 ] @@ -13,20 +13,33 @@ buildscript { return BunnyStreamReactNative[prop] } + // TODO: Pinned local snapshot of the Bunny Stream Android SDK 4.0.0 (live-stream branch). + // Published to mavenLocal() from bunny-stream-android-private @ fb86350 via: + // ./gradlew :api:publishToMavenLocal :player:publishToMavenLocal \ + // -Pversion=4.0.0-live.fb86350-SNAPSHOT + // See PLAN.md §2 and §7 Faza 0. Replace with the public Maven artifact and + // re-enable the build:android CI job before npm publish. + ext.bunnyStreamSdkVersion = "4.0.0-live.shadow.1-SNAPSHOT" + repositories { google() mavenCentral() + mavenLocal() } dependencies { classpath "com.android.tools.build:gradle:8.7.2" // noinspection DifferentKotlinGradleVersion classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + // Compose compiler plugin (built into Kotlin 2.0+) — required to host + // the SDK's BunnyLiveStreamPlayer composable in a ComposeView. + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:${getExtOrDefault('kotlinVersion')}" } } apply plugin: "com.android.library" apply plugin: "kotlin-android" +apply plugin: "org.jetbrains.kotlin.plugin.compose" apply plugin: "com.facebook.react" android { @@ -39,6 +52,10 @@ android { } compileOptions { + // SDK 4.0.0 requires core library desugaring (media3-exoplayer-ima / + // interactivemedia rely on java.time and other desugared APIs). Enabled + // here so consumers of the npm package inherit it without extra setup. + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } @@ -56,19 +73,48 @@ android { dependencies { implementation "com.facebook.react:react-android" - implementation "net.bunny:player:3.3.0" + implementation "net.bunny:player:${bunnyStreamSdkVersion}" // api is a runtime-scoped transitive dep of player; declare explicitly - // so BunnyStreamApi is on the compile classpath of the bridge. - implementation "net.bunny:api:3.3.0" + // so BunnyStreamApi and the live-stream domain models are on the compile + // classpath of the bridge (used by the live host and the TurboModule). + implementation "net.bunny:api:${bunnyStreamSdkVersion}" // media3 is a runtime-scoped transitive dep of player; declare explicitly // so Player.Listener and PlaybackException are on the compile classpath. - implementation "androidx.media3:media3-common:1.6.0" - // media3-ui provides PlayerView (useController / showController) which the - // native SDK's BunnyPlayerView extends. - implementation "androidx.media3:media3-ui:1.6.0" + implementation "androidx.media3:media3-common:1.10.1" + // media3-ui provides PlayerView (controlsEnabled) which the native SDK's + // BunnyPlayerView extends. + implementation "androidx.media3:media3-ui:1.10.1" // lifecycle is needed for setViewTreeLifecycleOwner — the native SDK uses - // findViewTreeLifecycleOwner() to register its lifecycle observer. - implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.7" + // findViewTreeLifecycleOwner() to register its lifecycle observer, and the + // live host uses LocalLifecycleOwner/ViewModelStoreOwner from lifecycle 2.10. + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.10.0" + // Compose runtime + ui + viewmodel needed to host BunnyLiveStreamPlayer + // (a Compose composable) inside a ComposeView backed by Fabric. The SDK + // declares these as `runtime` scope (transitive), so they are not on the + // compile classpath of the bridge — declare them explicitly. + // lifecycle-runtime (core) provides ViewTreeLifecycleOwner; -ktx alone + // does not expose it on the compile classpath. + implementation "androidx.lifecycle:lifecycle-runtime:2.10.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0" + implementation "androidx.lifecycle:lifecycle-viewmodel:2.10.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0" + implementation "androidx.lifecycle:lifecycle-runtime-compose:2.10.0" + // savedstate provides ViewTreeSavedStateRegistryOwner (set/get) for the + // ComposeView host — the SDK declares it as `runtime` scope (transitive). + implementation "androidx.savedstate:savedstate:1.4.0" + implementation platform("androidx.compose:compose-bom:2025.03.01") + implementation "androidx.compose.runtime:runtime" + implementation "androidx.compose.ui:ui" + implementation "androidx.compose.ui:ui-tooling-preview" + implementation "androidx.compose.foundation:foundation" + implementation "androidx.compose.material3:material3" + // AppCompat is needed so the bridge can provide a ContextThemeWrapper with + // an AppCompat dark theme. The SDK's controls use + // androidx.appcompat.widget.PopupMenu, which only renders a dark popup with + // an AppCompat parent theme (platform Theme.Material can leave it white-on-white). + implementation "androidx.appcompat:appcompat:1.7.1" + // Core library desugaring — required by SDK 4.0.0 transitive deps. + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5" testImplementation "org.jetbrains.kotlin:kotlin-test:${getExtOrDefault('kotlinVersion')}" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:${getExtOrDefault('kotlinVersion')}" diff --git a/android/gradle.properties b/android/gradle.properties index c573643..7cb9479 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -5,4 +5,6 @@ bunnyStreamReactNative_ndkVersion=27.1.12297006 # When built as a standalone project, this is the Kotlin version used. # When consumed via autolinking, the host app's Kotlin version takes precedence. -kotlinVersion=2.1.20 +# SDK 4.0.0 is built with Kotlin 2.2.20 and pulls kotlin-stdlib 2.3.x transitively; +# 2.2.20 is the minimum compiler that can read that metadata. +kotlinVersion=2.2.20 diff --git a/android/src/main/java/net/bunny/reactnative/BunnyStreamPlayerPackage.kt b/android/src/main/java/net/bunny/reactnative/BunnyStreamPlayerPackage.kt index 1335cd7..3dce4f7 100644 --- a/android/src/main/java/net/bunny/reactnative/BunnyStreamPlayerPackage.kt +++ b/android/src/main/java/net/bunny/reactnative/BunnyStreamPlayerPackage.kt @@ -7,15 +7,21 @@ import com.facebook.react.module.model.ReactModuleInfo import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager import net.bunny.reactnative.module.BunnyStreamPlayerModule +import net.bunny.reactnative.view.BunnyLiveStreamPlayerViewManager import net.bunny.reactnative.view.BunnyStreamPlayerViewManager /** * React Native package for the Bunny Stream player bridge. * * Registered automatically via autolinking — the host app does not need to - * add it manually to `PackageList`. Both the TurboModule ([BunnyStreamPlayerModule]) - * and the Fabric ViewManager ([BunnyStreamPlayerViewManager]) are declared here so - * that React Native can discover them on app startup. + * add it manually to `PackageList`. The TurboModule ([BunnyStreamPlayerModule]) + * and both Fabric ViewManagers ([BunnyStreamPlayerViewManager] for VOD and + * [BunnyLiveStreamPlayerViewManager] for live) are declared here so that + * React Native can discover them on app startup. + * + * The live ViewManager is internal to the bridge — the public npm API exposes + * a single `BunnyStreamPlayer` component that selects between the two hosts + * based on `source.type` (PLAN.md §5). */ class BunnyStreamPlayerPackage : BaseReactPackage() { override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = @@ -25,7 +31,10 @@ class BunnyStreamPlayerPackage : BaseReactPackage() { } override fun createViewManagers(reactContext: ReactApplicationContext): List> = - listOf(BunnyStreamPlayerViewManager()) + listOf( + BunnyStreamPlayerViewManager(), + BunnyLiveStreamPlayerViewManager(), + ) override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider { mapOf( diff --git a/android/src/main/java/net/bunny/reactnative/module/BunnyStreamPlayerModule.kt b/android/src/main/java/net/bunny/reactnative/module/BunnyStreamPlayerModule.kt index 360f07e..4e5fa5b 100644 --- a/android/src/main/java/net/bunny/reactnative/module/BunnyStreamPlayerModule.kt +++ b/android/src/main/java/net/bunny/reactnative/module/BunnyStreamPlayerModule.kt @@ -19,7 +19,10 @@ import net.bunny.reactnative.NativeBunnyStreamPlayerSpec class BunnyStreamPlayerModule(reactContext: ReactApplicationContext) : NativeBunnyStreamPlayerSpec(reactContext) { - override fun initialize(accessKey: String?, libraryId: Double) { + override fun initialize(accessKey: String, libraryId: Double) { + require(accessKey.isNotBlank()) { + "accessKey must be a non-empty string (SDK 4.0.0 requirement)" + } val libraryIdLong = validateLibraryId(libraryId) BunnyStreamApi.initialize( context = reactApplicationContext.applicationContext, diff --git a/android/src/main/java/net/bunny/reactnative/view/BunnyLiveStreamPlayerView.kt b/android/src/main/java/net/bunny/reactnative/view/BunnyLiveStreamPlayerView.kt new file mode 100644 index 0000000..fe4d783 --- /dev/null +++ b/android/src/main/java/net/bunny/reactnative/view/BunnyLiveStreamPlayerView.kt @@ -0,0 +1,470 @@ +package net.bunny.reactnative.view + +import android.content.Context +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import net.bunny.bunnystreamplayer.livestream.BunnyLiveStreamPlayer +import net.bunny.bunnystreamplayer.livestream.BunnyLiveStreamPlayerViewModel +import net.bunny.bunnystreamplayer.livestream.LiveStreamPlayerState +import net.bunny.reactnative.events.FabricEventEmitter +import net.bunny.reactnative.ownership.BunnyPlayerLease +import net.bunny.reactnative.state.RnEvent + +/** + * React Native Fabric wrapper that hosts the SDK's Compose + * [BunnyLiveStreamPlayer] composable inside a [ComposeView]. + * + * The SDK ships live playback as a Compose composable (not a classic `View`), + * so the bridge cannot reuse the VOD `BunnyStreamPlayerView` path. Instead we: + * + * 1. Wrap a [ComposeView] in this `FrameLayout`. + * 2. Propagate `LifecycleOwner` and `ViewModelStoreOwner` from a bridge-owned + * [HostingLifecycleOwner] to the Compose tree (the composable calls + * `viewModel(...)` and `collectAsStateWithLifecycle()`, both of which need + * those owner present in the view tree). `SavedStateRegistryOwner` is not + * set — the SDK live composable does not use `rememberSaveable`, and + * `ComposeView` creates its own `SavedStateRegistryOwner` from the + * `LifecycleOwner` when one is not found in the view tree. + * 3. Create a bridge-owned [BunnyLiveStreamPlayerViewModel] via + * [ViewModelProvider] tied to [hostingOwner]'s `ViewModelStore`, and pass + * it to the composable via the `viewModel` parameter. This avoids the + * composable creating its own ViewModel (which would double-poll) and lets + * the bridge collect `state` / `terminalError` and forward them to JS. + * 4. Render [BunnyLiveStreamPlayer] with the source props. The composable + * owns polling, the state resolver, countdown/trailer overlays, DVR, + * recovery and the live → VOD hand-off — the bridge does not reimplement + * any of it (PLAN.md §6 Faza 4: no resolver/polling duplication in JS). + * 5. Forward `onVideoSizeChanged` and `onLiveStateChange` to JS via the + * Fabric emitter. `onLiveStateChange` carries the SDK's + * [LiveStreamPlayerState] (loading / offline / countdown / trailer / live + * / vod) plus an `isLive` boolean, so JS can drive custom UI without + * duplicating the state resolver. + * + * Source changes (`streamId`/`libraryId`/`token`/`expires`) trigger a + * controlled recomposition: the composable's `LaunchedEffect(libraryId, + * streamId)` re-runs and calls `viewModel.start(...)`, which is idempotent for + * the same `streamId` but starts a new stream when the id changes. Because the + * SDK ViewModel ignores a second `start()` with a different stream (see + * `BunnyLiveStreamPlayerViewModel`), the ViewManager remounts this view on + * `streamId` change via a `key` prop in the public TS wrapper (PLAN.md §5 + * Faza 6: reset on source identity change). + * + * Lifecycle: the composable's `DisposableEffect` observes + * `LocalLifecycleOwner.current` (our [HostingLifecycleOwner]) and calls + * `viewModel.onForeground()/onBackground()` on ON_START/ON_STOP. We forward + * the **host Activity's** ON_START/ON_STOP to our hosting owner so polling + * pauses when the app goes to background (not just when the view detaches). + * On detach we additionally dispatch ON_STOP as a safety net. + * + * State collection: we collect `viewModel.state` and `viewModel.terminalError` + * in a [CoroutineScope] tied to the view, using `repeatOnLifecycle(STARTED)` + * so collection pauses when the lifecycle drops below STARTED (matching the + * composable's own `collectAsStateWithLifecycle` behaviour). + * + * Lease: the live composable internally creates a [BunnyStreamPlayer] which + * uses the `DefaultBunnyPlayer` singleton — the same engine the VOD path + * uses. We acquire a [BunnyPlayerLease] on attach so that mounting a live + * view revokes any active VOD lease (and vice versa). This does NOT fully + * isolate the engines (the live composable's internal `BunnyStreamPlayer` + * bypasses the bridge), but it ensures the VOD view cleans up when live + * mounts. Concurrent VOD + live is not supported. + * + * Cleanup: `onDropViewInstance` calls [cleanup], which cancels the coroutine + * scope, dispatches `ON_DESTROY` (disposing the composition, cancelling + * polling and releasing the player), clears the ViewModelStore (calling + * `viewModel.onCleared()`, cancelling `viewModelScope`), and releases the + * lease. + */ +class BunnyLiveStreamPlayerView( + context: Context, +) : FrameLayout(context) { + + /** Event emitter for Fabric direct events. Null if not a ReactContext. */ + private val emitter: FabricEventEmitter? = FabricEventEmitter.forView(this) + + /** + * Owner that provides `Lifecycle` and `ViewModelStore` to the Compose tree + * hosted in [composeView]. We create our own (rather than reusing the + * Activity's) so that disposing this view's composition does not tear down + * the host Activity's state, and so the bridge can explicitly start/stop + * the lifecycle when the view attaches/detaches or the app goes to + * background/foreground. + */ + private val hostingOwner = HostingLifecycleOwner() + + /** + * Bridge-owned [BunnyLiveStreamPlayerViewModel], tied to [hostingOwner]'s + * `ViewModelStore` via [ViewModelProvider]. Passed to the composable so it + * doesn't create its own (which would double-poll). The composable's + * `LaunchedEffect(libraryId, streamId)` calls `viewModel.start(...)` on this + * instance, and its `DisposableEffect` observes our [hostingOwner] lifecycle + * to call `onForeground()/onBackground()`. + * + * `onCleared()` is called when the `ViewModelStore` is cleared in [cleanup]. + */ + private val viewModel: BunnyLiveStreamPlayerViewModel by lazy { + ViewModelProvider(hostingOwner)[BunnyLiveStreamPlayerViewModel::class.java] + } + + /** + * Coroutine scope for collecting `viewModel.state` / `terminalError`. + * Cancelled in [cleanup]. Uses `Dispatchers.Main.immediate` so events are + * dispatched on the UI thread without a context switch. + */ + private val stateScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + /** + * Lease on the `DefaultBunnyPlayer` singleton. Acquired on attach, released + * on cleanup. Ensures the VOD view is revoked when live mounts. + */ + private var lease: BunnyPlayerLease? = null + + /** + * Observer on the host Activity's lifecycle, used to forward ON_START/ON_STOP + * to [hostingOwner] so polling pauses when the app goes to background. + * Removed on detach. + */ + private var activityLifecycle: LifecycleOwner? = null + private var activityObserver: LifecycleEventObserver? = null + + /** The Compose host. Added as the only child, sized to fill. */ + private val composeView: ComposeView = ComposeView(context).also { cv -> + // Propagate owners to the ComposeView so the SDK composable's + // viewModel() / collectAsStateWithLifecycle() find them in the view tree. + cv.setViewTreeLifecycleOwner(hostingOwner) + cv.setViewTreeViewModelStoreOwner(hostingOwner) + // Dispose the composition when the lifecycle reaches DESTROYED — Fabric + // can detach/reattach during recycle, and we don't want a dangling + // composition holding a player. The composable's DisposableEffect runs + // its cleanup (viewModel.onBackground) on dispose. + cv.setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, + ) + addView(cv, LayoutParams(MATCH_PARENT, MATCH_PARENT)) + } + + /** Latest committed source props. */ + private var source: LiveSource = LiveSource.Empty + + /** Idempotent cleanup guard. */ + private var cleanedUp = false + + init { + // Propagate our hosting owner down from this FrameLayout too, so any + // view-tree walk that starts above composeView still finds the owners. + setViewTreeLifecycleOwner(hostingOwner) + setViewTreeViewModelStoreOwner(hostingOwner) + + // Start collecting live state and terminal errors. repeatOnLifecycle + // pauses collection when the lifecycle drops below STARTED, matching + // the composable's own collectAsStateWithLifecycle behaviour. + stateScope.launch { + hostingOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.state.collect { state -> + emitter?.dispatch( + RnEvent("onLiveStateChange") { + liveStateToPayload(state) + }, + ) + } + } + launch { + viewModel.terminalError.collect { error -> + if (error != null) { + emitter?.dispatch( + RnEvent("onLiveError") { + mapOf("message" to error) + }, + ) + } + } + } + } + } + } + + // --- Prop accumulation --- + + private var pendingLibraryId: Long = 0L + private var pendingStreamId: String = "" + private var pendingToken: String? = null + private var pendingExpires: Long? = null + + fun setLibraryId(value: Double) { + if (value.isFinite() && value > 0 && value % 1.0 == 0.0) { + pendingLibraryId = value.toLong() + } + } + + fun setStreamId(value: String?) { + pendingStreamId = value.orEmpty() + } + + fun setToken(value: String?) { + pendingToken = value + } + + fun setExpires(value: Double) { + if (value.isFinite() && value >= 0 && value % 1.0 == 0.0) { + pendingExpires = value.toLong() + } else { + pendingExpires = null + } + } + + /** + * Snapshots accumulated props and (re)composes if the source identity + * changed. Called from the ViewManager's `onAfterUpdateTransaction`. + */ + fun commitProps() { + val next = LiveSource( + libraryId = pendingLibraryId, + streamId = pendingStreamId, + token = pendingToken, + expires = pendingExpires, + ) + val prev = source + source = next + if (next == prev) return + if (next.streamId.isBlank()) return + composeView.setContent { LivePlayerContent(next) } + } + + /** + * The Compose content: a thin wrapper that forwards `onVideoSizeChanged` + * to the Fabric emitter and renders the SDK composable with our + * bridge-owned [viewModel]. + */ + @Composable + private fun LivePlayerContent(src: LiveSource) { + val em = emitter + val onSize: ((Int, Int) -> Unit)? = em?.let { e -> + { width, height -> + e.dispatch( + RnEvent("onVideoSizeChange") { + mapOf("width" to width, "height" to height) + }, + ) + } + } + // remember(src) so the lambda identity is stable for a given source — + // avoids unnecessary recompositions of the SDK composable. + val rememberedOnSize = remember(src) { onSize } + BunnyLiveStreamPlayer( + libraryId = src.libraryId, + streamId = src.streamId, + token = src.token, + expires = src.expires, + modifier = Modifier, + onVideoSizeChanged = rememberedOnSize, + viewModel = viewModel, + ) + } + + // --- Lifecycle --- + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + // Acquire the player lease — revokes any active VOD lease so the VOD + // view cleans up. The live composable uses the same DefaultBunnyPlayer + // singleton internally; concurrent VOD + live is not supported. + if (lease == null) { + lease = BunnyPlayerLease(onRevoke = { + // Another view (VOD) is taking ownership — pause our polling. + hostingOwner.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + }).also { it.acquire() } + } + + // Track the host Activity's lifecycle so polling pauses when the app + // goes to background (not just when the view detaches). We look up + // the parent's lifecycle owner because we set our own on `this`. + val activityOwner = findActivityLifecycleOwner() + if (activityOwner != null && activityLifecycle !== activityOwner) { + // Remove any previous observer if the parent changed. + activityLifecycle?.let { old -> + activityObserver?.let { obs -> old.lifecycle.removeObserver(obs) } + } + activityLifecycle = activityOwner + val obs = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> hostingOwner.handleLifecycleEvent(Lifecycle.Event.ON_START) + Lifecycle.Event.ON_STOP -> hostingOwner.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + else -> Unit + } + } + activityObserver = obs + activityOwner.lifecycle.addObserver(obs) + } + + // Start the hosted lifecycle when the view attaches — the SDK composable's + // DisposableEffect observes ON_START to begin polling. If we're tracking + // the Activity lifecycle, the observer above will also fire ON_START if + // the Activity is currently started; this duplicate is harmless because + // LifecycleRegistry deduplicates state transitions. + hostingOwner.handleLifecycleEvent(Lifecycle.Event.ON_START) + } + + override fun onDetachedFromWindow() { + // Pause polling before detaching — ON_STOP triggers viewModel.onBackground(). + hostingOwner.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + // Stop observing the Activity lifecycle while detached. + activityLifecycle?.let { old -> + activityObserver?.let { obs -> old.lifecycle.removeObserver(obs) } + } + activityLifecycle = null + activityObserver = null + super.onDetachedFromWindow() + } + + /** + * Finds the host Activity's [LifecycleOwner] by walking up the parent view + * tree (skipping `this`, which has our [HostingLifecycleOwner]). + */ + private fun findActivityLifecycleOwner(): LifecycleOwner? { + val p = parent as? android.view.View ?: return null + return p.findViewTreeLifecycleOwner() + } + + // --- Sizing / layout (same as VOD view) --- + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = MeasureSpec.getSize(heightMeasureSpec) + setMeasuredDimension(width, height) + measureChildWithMargins(composeView, widthMeasureSpec, 0, heightMeasureSpec, 0) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + if (childCount == 0) return + val child = getChildAt(0) + child.layout(0, 0, right - left, bottom - top) + } + + // --- Cleanup --- + + /** Called from ViewManager.onDropViewInstance. Idempotent. */ + fun cleanup() { + if (cleanedUp) return + cleanedUp = true + // Cancel state collection coroutines. + stateScope.cancel() + // Remove Activity lifecycle observer if still attached. + activityLifecycle?.let { old -> + activityObserver?.let { obs -> old.lifecycle.removeObserver(obs) } + } + activityLifecycle = null + activityObserver = null + // Dispatch ON_DESTROY — disposes the composition (DisposeOnViewTreeLifecycleDestroyed), + // which runs the composable's DisposableEffect cleanup (viewModel.onBackground). + hostingOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + // Clear the ViewModelStore — calls viewModel.onCleared(), cancelling + // viewModelScope coroutines including any in-flight polling. + hostingOwner.clearStore() + // Release the player lease. + lease?.release() + lease = null + removeAllViews() + } + + /** + * Maps the SDK's [LiveStreamPlayerState] sealed interface to a JS-friendly + * payload. `state` is a lowercase string matching the SDK's branch names; + * `isLive` is `true` only for [LiveStreamPlayerState.LivePlay]. + */ + private fun liveStateToPayload(state: LiveStreamPlayerState): Map = + when (state) { + is LiveStreamPlayerState.Loading -> mapOf( + "state" to "loading", + "isLive" to false, + ) + is LiveStreamPlayerState.Offline -> mapOf( + "state" to "offline", + "isLive" to false, + "reason" to state.reason.name.lowercase(), + ) + is LiveStreamPlayerState.Countdown -> mapOf( + "state" to "countdown", + "isLive" to false, + "targetEpochMs" to state.targetEpochMs, + "title" to state.title, + ) + is LiveStreamPlayerState.Trailer -> mapOf( + "state" to "trailer", + "isLive" to false, + ) + is LiveStreamPlayerState.LivePlay -> mapOf( + "state" to "live", + "isLive" to true, + "dvrEnabled" to state.dvrEnabled, + ) + is LiveStreamPlayerState.VodPlay -> mapOf( + "state" to "vod", + "isLive" to false, + ) + } + + /** Immutable snapshot of the live source props. */ + private data class LiveSource( + val libraryId: Long, + val streamId: String, + val token: String?, + val expires: Long?, + ) { + companion object { + val Empty = LiveSource(0L, "", null, null) + } + } + + companion object { + private val MATCH_PARENT = ViewGroup.LayoutParams.MATCH_PARENT + } +} + +/** + * A combined `LifecycleOwner` + `ViewModelStoreOwner` that the bridge controls + * explicitly. This decouples the hosted Compose tree's lifecycle from the host + * Activity's so that detaching this view (Fabric can recycle views) does not + * tear down the Activity's state, and so the bridge can drive + * `ON_START`/`ON_STOP`/`ON_DESTROY` at attach/detach/drop time or when the + * Activity goes to background/foreground. + */ +private class HostingLifecycleOwner : LifecycleOwner, ViewModelStoreOwner { + + private val lifecycleRegistry = LifecycleRegistry(this) + private val store = ViewModelStore() + + override val lifecycle: Lifecycle get() = lifecycleRegistry + override val viewModelStore: ViewModelStore get() = store + + fun handleLifecycleEvent(event: Lifecycle.Event) { + lifecycleRegistry.handleLifecycleEvent(event) + } + + /** Clears the ViewModelStore, cancelling all viewModelScope coroutines. */ + fun clearStore() { + store.clear() + } +} diff --git a/android/src/main/java/net/bunny/reactnative/view/BunnyLiveStreamPlayerViewManager.kt b/android/src/main/java/net/bunny/reactnative/view/BunnyLiveStreamPlayerViewManager.kt new file mode 100644 index 0000000..daa46ea --- /dev/null +++ b/android/src/main/java/net/bunny/reactnative/view/BunnyLiveStreamPlayerViewManager.kt @@ -0,0 +1,93 @@ +package net.bunny.reactnative.view + +import com.facebook.react.uimanager.BaseViewManagerDelegate +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.viewmanagers.BunnyLiveStreamPlayerViewManagerDelegate +import com.facebook.react.viewmanagers.BunnyLiveStreamPlayerViewManagerInterface + +/** + * Fabric ViewManager for the live-stream host view. + * + * Implements the Codegen-generated [BunnyLiveStreamPlayerViewManagerInterface] + * and routes prop updates through [BunnyLiveStreamPlayerViewManagerDelegate]. + * The manager name `BunnyLiveStreamPlayerView` matches the Codegen component + * name exactly. + * + * This manager is registered in [net.bunny.reactnative.BunnyStreamPlayerPackage] + * but the corresponding native component is NOT exported from the public npm + * API — the public `BunnyStreamPlayer` (src/index.tsx) selects between the VOD + * host and this live host based on `source.type` (PLAN.md §5). + * + * Prop setters delegate to [BunnyLiveStreamPlayerView]'s accumulation fields; + * the actual composition happens in [BunnyLiveStreamPlayerView.commitProps], + * called from [onAfterUpdateTransaction] after all props in a batch are set. + * + * No commands today — the SDK does not yet expose a public live controller + * (PLAN.md §6 Faza 5). When it does, command methods will be added here and + * in the Codegen spec's `NativeCommands`. + */ +class BunnyLiveStreamPlayerViewManager : + SimpleViewManager(), + BunnyLiveStreamPlayerViewManagerInterface { + + private var delegate: + BunnyLiveStreamPlayerViewManagerDelegate? = + null + + override fun getName(): String = NAME + + override fun getDelegate(): + BaseViewManagerDelegate { + if (delegate == null) { + delegate = BunnyLiveStreamPlayerViewManagerDelegate(this) + } + return delegate!! + } + + override fun createViewInstance(reactContext: ThemedReactContext): BunnyLiveStreamPlayerView = + BunnyLiveStreamPlayerView(reactContext) + + override fun onAfterUpdateTransaction(view: BunnyLiveStreamPlayerView) { + super.onAfterUpdateTransaction(view) + view.commitProps() + } + + override fun onDropViewInstance(view: BunnyLiveStreamPlayerView) { + view.cleanup() + super.onDropViewInstance(view) + } + + override fun getExportedCustomDirectEventTypeConstants(): Map = + (super.getExportedCustomDirectEventTypeConstants() ?: emptyMap()).toMutableMap().apply { + putAll(DIRECT_EVENTS) + } + + // --- Prop setters (delegate calls these during a prop batch) --- + + override fun setLibraryId(view: BunnyLiveStreamPlayerView, value: Double) { + view.setLibraryId(value) + } + + override fun setStreamId(view: BunnyLiveStreamPlayerView, value: String?) { + view.setStreamId(value) + } + + override fun setToken(view: BunnyLiveStreamPlayerView, value: String?) { + view.setToken(value) + } + + override fun setExpires(view: BunnyLiveStreamPlayerView, value: Double) { + view.setExpires(value) + } + + companion object { + const val NAME = "BunnyLiveStreamPlayerView" + + private val DIRECT_EVENTS = mapOf( + "topVideoSizeChange" to mapOf("registrationName" to "onVideoSizeChange"), + "topLiveStateChange" to mapOf("registrationName" to "onLiveStateChange"), + "topLiveError" to mapOf("registrationName" to "onLiveError"), + ) + } +} diff --git a/android/src/main/java/net/bunny/reactnative/view/BunnyStreamPlayerView.kt b/android/src/main/java/net/bunny/reactnative/view/BunnyStreamPlayerView.kt index 68f6e3d..613bd47 100644 --- a/android/src/main/java/net/bunny/reactnative/view/BunnyStreamPlayerView.kt +++ b/android/src/main/java/net/bunny/reactnative/view/BunnyStreamPlayerView.kt @@ -2,17 +2,22 @@ package net.bunny.reactnative.view import android.annotation.SuppressLint import android.content.Context +import android.graphics.Color import android.view.ContextThemeWrapper import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import kotlin.math.ceil +import android.widget.TextView import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.PlayerView import net.bunny.bunnystreamplayer.DefaultBunnyPlayer import net.bunny.bunnystreamplayer.ui.BunnyPlayer import net.bunny.bunnystreamplayer.ui.BunnyStreamPlayer +import net.bunny.bunnystreamplayer.ui.widget.BunnyPlayerView +import net.bunny.reactnative.R import net.bunny.reactnative.adapter.PlayerEventListener import net.bunny.reactnative.commands.CommandQueue import net.bunny.reactnative.commands.GenerationToken @@ -20,31 +25,47 @@ import net.bunny.reactnative.commands.PlayerCommand import net.bunny.reactnative.events.FabricEventEmitter import net.bunny.reactnative.ownership.BunnyPlayerLease import net.bunny.reactnative.state.BunnyStreamPlayerProps +import kotlin.math.ceil /** - * React Native wrapper around the native [BunnyStreamPlayer]. + * React Native wrapper around the native [BunnyStreamPlayer] (SDK 4.0.0). * * Holds exactly one SDK player instance as a child with `MATCH_PARENT` in both * dimensions. Separates prop accumulation (individual setters called by the * Fabric delegate) from prop application ([commitProps], called from the * ViewManager's `onAfterUpdateTransaction`). * - * Key behaviours: + * SDK 4.0.0 migration (PLAN.md §7 Faza 2): + * - Native controls toggle via the public `BunnyStreamPlayer.controlsEnabled` + * instead of reaching into the internal Media3 `PlayerView`. + * - Progress comes from the SDK's `BunnyPlayer.ProgressListener` (the SDK polls + * Media3 itself every 250 ms while playing); the bridge no longer runs its + * own progress polling Runnable. + * - Playback rate is set through the public `BunnyStreamPlayer.playbackSpeed` + * property; mute/unmute through `mute()`/`unmute()`. Volume stays on the + * `DefaultBunnyPlayer` singleton because the view does not expose a volume + * setter (PLAN.md §5 Faza 2 — isolated adapter). + * - Public SDK callbacks (`onPlayingChanged`, `onMutedChanged`, + * `onPlaybackSpeedChanged`, `onVideoSizeChanged`, `onPlaybackError`) are + * forwarded to JS. The Media3 `Player.Listener` adapter is retained only for + * the state-machine semantics the SDK view does not surface directly + * (ready/end/buffering), per PLAN.md §5 Faza 2. + * - The `exo_position` width repair and the 100 ms `currentPlayer` polling are + * kept as the minimal adapter for ready/end/buffering; the SDK view exposes + * no callback for `currentPlayer` recreation, so the bridge still has to + * discover the new ExoPlayer to attach the state-machine listener. + * + * Key behaviours preserved from 3.3.0: * - Video reload only when the committed props snapshot actually changes. * - [GenerationToken] invalidates stale callbacks from previous loads. * - `autoPlay=false` pauses after `STATE_READY`; toggling `autoPlay` for an * already-loaded video calls `play`/`pause` without reloading. * - `play`/`pause`/`seekTo` are routed through a [CommandQueue] with a - * ready-gate: before `STATE_READY` they are held and drained once the - * player becomes ready. `setVolume`/`setPlaybackRate` bypass the queue - * and target the [DefaultBunnyPlayer] singleton directly. - * - A [PlayerEventListener] registers `Player.Listener` on - * `DefaultBunnyPlayer.currentPlayer` (not the SDK's `playerStateListener` - * slot, which is occupied by the native UI) and translates Media3 callbacks - * into RN direct events via the state machine. + * ready-gate. `setVolume`/`setPlaybackRate`/`mute`/`unmute` bypass the queue + * and target the [DefaultBunnyPlayer] singleton directly (available after + * `initialize`). * - A [BunnyPlayerLease] enforces single-active-instance ownership of the - * `DefaultBunnyPlayer` singleton. When a new view mounts, the previous - * owner's lease is revoked, triggering its cleanup. + * `DefaultBunnyPlayer` singleton. */ class BunnyStreamPlayerView( context: Context, @@ -53,16 +74,28 @@ class BunnyStreamPlayerView( /** * Dark-theme wrapper so controller TextViews inflate with white text. * RN's DayNight theme would otherwise override `android:textColor` to dark. + * + * We use a custom [BunnyReactNativePlayerTheme] that extends + * [Theme_AppCompat_NoActionBar] because the SDK's + * `androidx.appcompat.widget.PopupMenu` (opened by the settings gear) only + * reliably renders a dark popup from an AppCompat parent theme. Platform + * `Theme.Material` was leaving the popup background white while the text + * stayed white, producing white-on-white menu items. */ private val playerContext: Context = ContextThemeWrapper( context, - android.R.style.Theme_Black_NoTitleBar, + R.style.BunnyReactNativePlayerTheme, ) /** The native SDK player, sized to fill this wrapper. */ val player: BunnyStreamPlayer = BunnyStreamPlayer(playerContext).also { child -> - // Disabled: PixelCopy reads wrong pixels in RN Fabric, causing text to flip dark. - // child.autoProgressTextColor = true + // White progress/duration text with a dark drop shadow — the SDK draws a + // black shadow behind the readout, giving the "double text" effect (white + // text with a dark halo) like YouTube's player controls. The popup menu + // still follows the system DayNight theme; this color choice is for the + // progress bar readout only. + child.autoProgressTextColor = false + child.progressTextColor = Color.WHITE addView( child, LayoutParams(MATCH_PARENT, MATCH_PARENT), @@ -72,6 +105,13 @@ class BunnyStreamPlayerView( /** Monotonic token for cancelling stale async callbacks. */ val generationToken = GenerationToken() + /** + * Last volume set via [setVolume], tracked so [onMutedChanged] can emit the + * real volume when unmuting instead of defaulting to 1.0. Defaults to 1.0 + * (the SDK's initial volume) when [setVolume] was never called. + */ + private var lastKnownVolume: Float = 1f + /** * Ownership lease for the `DefaultBunnyPlayer` singleton. * Acquired on mount; revoked (via callback) when a newer view takes over; @@ -86,7 +126,7 @@ class BunnyStreamPlayerView( /** Event emitter for Fabric direct events. Null if context is not a ReactContext. */ private val emitter: FabricEventEmitter? = FabricEventEmitter.forView(this) - /** Translates Media3 Player.Listener callbacks into RN events. */ + /** Translates Media3 Player.Listener callbacks into RN events (ready/end/buffering). */ private val eventListener: PlayerEventListener? = emitter?.let { em -> PlayerEventListener( emitter = em, @@ -105,7 +145,7 @@ class BunnyStreamPlayerView( } } - /** Progress listener registered on the SDK view (tick ~250 ms). */ + /** Progress listener registered on the SDK view (tick ~250 ms while playing). */ private val progressListener = object : BunnyPlayer.ProgressListener { override fun onProgressChanged(position: Long, duration: Long, progress: Float) { eventListener?.onProgress(position, duration) @@ -131,9 +171,72 @@ class BunnyStreamPlayerView( override fun onViewDetachedFromWindow(view: View) = Unit }) + installSdkCallbacks() lease.acquire() } + /** + * Wires the public SDK 4.0.0 callbacks on [player] to the Fabric event + * emitter. These complement the Media3 state-machine adapter + * ([PlayerEventListener]), which still owns ready/end/buffering because the + * SDK view does not surface those transitions as public callbacks. + */ + private fun installSdkCallbacks() { + val em = emitter ?: return + player.onPlayingChanged = { _ -> + // The state machine in PlayerEventListener already derives play/pause + // from Media3's onIsPlayingChanged; forwarding here would double-emit. + // Kept as a no-op hook for future SDK-only state sourcing (PLAN.md §5 + // Faza 2: keep event names stable while the adapter owns semantics). + } + player.onMutedChanged = { isMuted -> + if (generationToken.isActive(playbackGeneration)) { + // Emit the real volume when unmuting (tracked via [lastKnownVolume]), + // not a hardcoded 1.0 — the user may have set volume to 0.3 before + // muting, and unmuting should restore that value, not jump to max. + val effectiveVolume = if (isMuted) 0f else lastKnownVolume + em.dispatch( + net.bunny.reactnative.state.RnEvent("onVolumeChange") { + mapOf("volume" to effectiveVolume, "isMuted" to isMuted) + }, + ) + } + } + player.onPlaybackSpeedChanged = { speed -> + if (generationToken.isActive(playbackGeneration)) { + em.dispatch( + net.bunny.reactnative.state.RnEvent("onPlaybackRateChange") { + mapOf("rate" to speed) + }, + ) + } + } + player.onVideoSizeChanged = { width, height -> + if (generationToken.isActive(playbackGeneration)) { + em.dispatch( + net.bunny.reactnative.state.RnEvent("onVideoSizeChange") { + mapOf("width" to width, "height" to height) + }, + ) + } + } + player.onPlaybackError = { message -> + if (generationToken.isActive(playbackGeneration)) { + // The Media3 adapter already emits the structured onError/onPlaybackStateChange + // pair from onPlayerErrorChanged; this hook surfaces the SDK's human-readable + // message for the live recovery path and future custom-error UI. + em.dispatch( + net.bunny.reactnative.state.RnEvent("onPlaybackError") { + mapOf("message" to message) + }, + ) + } + } + } + + /** Generation captured when the current source started loading. */ + private var playbackGeneration: Long = 0L + override fun onAttachedToWindow() { super.onAttachedToWindow() // Fallback: if context was not a LifecycleOwner (e.g. wrapper context), @@ -191,7 +294,7 @@ class BunnyStreamPlayerView( pendingControls = value } - // --- Prop application (called from ViewManager.onAfterUpdateTransaction) --- + // --- Prop application (called from ViewManager.onAfterUpdatedTransaction) --- /** * Snapshots the accumulated prop fields into an immutable [BunnyStreamPlayerProps], @@ -240,7 +343,7 @@ class BunnyStreamPlayerView( * and re-attaches the [PlayerEventListener] to the new `currentPlayer`. */ private fun reloadVideo(props: BunnyStreamPlayerProps) { - generationToken.bump() + playbackGeneration = generationToken.bump() commandQueue.reset() applyControls(props.controls) val previousPlayer = DefaultBunnyPlayer.getInstance(context).currentPlayer @@ -261,10 +364,15 @@ class BunnyStreamPlayerView( * Polls [DefaultBunnyPlayer.currentPlayer] every 100ms until it becomes * non-null (the SDK has created the ExoPlayer), then attaches the event * listener. + * + * Retained from 3.3.0: the SDK view exposes no public callback for + * `currentPlayer` recreation, so the bridge must discover the new ExoPlayer + * to attach the ready/end/buffering state-machine listener. This is the + * minimal Media3 adapter allowed by PLAN.md §5 Faza 2. */ @SuppressLint("UnsafeOptInUsageError") private fun attachWhenPlayerReady(previousPlayer: androidx.media3.common.Player?) { - val gen = generationToken.current() + val gen = playbackGeneration var attempts = 0 post { val poll = object : Runnable { @@ -273,17 +381,7 @@ class BunnyStreamPlayerView( val cp = DefaultBunnyPlayer.getInstance(context).currentPlayer if (cp != null && cp !== previousPlayer) { eventListener?.attach() - startProgressPolling(cp, gen) - // Keep the original controls lifecycle: it lets the SDK finish - // replacing Media3's initial layout with Bunny's own layout. - postDelayed({ - applyControls(committedProps.controls) - repairInlinePositionWidth() - // Re-check a few times: the parent ConstraintLayout can override - // the width on the next pass, so re-apply until it sticks. - postDelayed({ repairInlinePositionWidth() }, 300) - postDelayed({ repairInlinePositionWidth() }, 700) - }, 500) + restoreNativeControllerLayout() } else if (attempts++ < 50) { postDelayed(this, 100) } @@ -293,104 +391,67 @@ class BunnyStreamPlayerView( } } - /** - * Traverses the view hierarchy to find the [BunnyPlayerView] (Media3 - * `PlayerView`) inside the native SDK's `BunnyStreamPlayer`. - */ - private fun findPlayerView(): androidx.media3.ui.PlayerView? { - return player.findViewById(net.bunny.player.R.id.player_view) - as? androidx.media3.ui.PlayerView - } - - /** Emits position updates directly from Media3; this does not depend on the SDK UI lifecycle. */ - private fun startProgressPolling(mediaPlayer: androidx.media3.common.Player, gen: Long) { - val poll = object : Runnable { - override fun run() { - if (!generationToken.isActive(gen)) return - val duration = mediaPlayer.duration - if (duration > 0) { - eventListener?.onProgress(mediaPlayer.currentPosition, duration) - } - postDelayed(this, 250) - } - } - post(poll) + /** Applies controller visibility through the public SDK 4.0.0 property. */ + private fun applyControls(showControls: Boolean) { + player.controlsEnabled = showControls } - /** Applies controller visibility without reloading the current video. */ - private fun applyControls(showControls: Boolean) { - findPlayerView()?.let { playerView -> + /** + * Compatibility adapter for SDK 4.0.0: setting `controlsEnabled = true` + * currently only changes Media3's `useController` flag. Under Fabric the + * controller can therefore remain hidden or retain a zero-width position + * label after the playback engine is replaced. + * + * TODO(Android SDK): remove this adapter after `controlsEnabled = true` + * shows and lays out the controller itself in the public Android SDK. + */ + private fun restoreNativeControllerLayout() { + postDelayed({ + val playerView = findPlayerView() ?: return@postDelayed + val showControls = committedProps.controls playerView.useController = showControls if (!showControls) { playerView.hideController() - return + return@postDelayed } - playerView.controllerShowTimeoutMs = androidx.media3.ui.PlayerControlView.DEFAULT_SHOW_TIMEOUT_MS + playerView.controllerShowTimeoutMs = PlayerControlView.DEFAULT_SHOW_TIMEOUT_MS playerView.showController() + val controller = playerView.findViewById(androidx.media3.ui.R.id.exo_controller) - if ( - controller != null && - playerView.width > 0 && - playerView.height > 0 && - (controller.width == 0 || controller.height == 0) - ) { - val widthSpec = View.MeasureSpec.makeMeasureSpec(playerView.width, View.MeasureSpec.EXACTLY) - val heightSpec = View.MeasureSpec.makeMeasureSpec(playerView.height, View.MeasureSpec.EXACTLY) + if (controller != null && playerView.width > 0 && playerView.height > 0) { + val widthSpec = MeasureSpec.makeMeasureSpec(playerView.width, MeasureSpec.EXACTLY) + val heightSpec = MeasureSpec.makeMeasureSpec(playerView.height, MeasureSpec.EXACTLY) controller.measure(widthSpec, heightSpec) controller.layout(0, 0, playerView.width, playerView.height) - // The direct layout gives Media3 a non-zero controller immediately, - // then let Android perform a normal hierarchy pass for the custom - // Bunny ConstraintLayout children (notably exo_position). - playerView.post { - controller.requestLayout() - playerView.requestLayout() - } + controller.requestLayout() } - } + playerView.requestLayout() + repairInlinePositionWidth(playerView) + postDelayed({ repairInlinePositionWidth(playerView) }, 300) + postDelayed({ repairInlinePositionWidth(playerView) }, 700) + }, 500) } - /** - * Bunny's custom controller can leave the visible `exo_position` TextView - * with a zero width when it is first attached under a Fabric-hosted view. - * The fullscreen Activity gets a fresh normal layout and is unaffected. - * - * We set layout params to the text's measured width, force a layout pass on - * the controller and player view, and also directly lay out the TextView so - * it is visible immediately even if the parent ConstraintLayout pass happens - * asynchronously or repeatedly restores 0dp width. - */ - private fun repairInlinePositionWidth() { - val playerView = findPlayerView() ?: return - val position = playerView.findViewById(androidx.media3.ui.R.id.exo_position) + private fun findPlayerView(): PlayerView? = + player.findViewById(net.bunny.player.R.id.player_view) + + /** Repairs the SDK controller's position label when Fabric leaves it at 0 px. */ + private fun repairInlinePositionWidth(playerView: PlayerView) { + val position = playerView.findViewById(androidx.media3.ui.R.id.exo_position) ?: return if (position.visibility != View.VISIBLE || position.width > 0) return - val text = position.text?.toString() ?: "" + val text = position.text?.toString().orEmpty() if (text.isEmpty()) return - val textWidth = ceil(position.paint.measureText(text)).toInt() + position.compoundPaddingLeft + position.compoundPaddingRight if (textWidth <= 0) return - // Lock the view to its text width so ConstraintLayout can no longer keep - // it at 0dp. - val layoutParams = position.layoutParams - layoutParams.width = textWidth - position.layoutParams = layoutParams - - // Force the player view / controller to perform a new layout pass. - val controller = playerView.findViewById(androidx.media3.ui.R.id.exo_controller) - controller?.requestLayout() - controller?.invalidate() + position.layoutParams = position.layoutParams.also { it.width = textWidth } + playerView.findViewById(androidx.media3.ui.R.id.exo_controller)?.requestLayout() playerView.requestLayout() - playerView.invalidate() - - // Direct layout as a safety net: the parent may not have laid this child - // yet, so place it at the current left/top with the correct right edge. - val top = position.top - val bottom = position.bottom - position.layout(position.left, top, position.left + textWidth, bottom) + position.layout(position.left, position.top, position.left + textWidth, position.bottom) } /** @@ -439,42 +500,52 @@ class BunnyStreamPlayerView( /** * Sets volume on the [DefaultBunnyPlayer] singleton directly — bypasses * the command queue because the singleton is available after `initialize` - * and does not depend on `STATE_READY`. + * and does not depend on `STATE_READY`. The SDK view does not expose a + * volume setter (only `mute()`/`unmute()`), so volume stays on the + * singleton as an isolated adapter (PLAN.md §5 Faza 2). */ fun setVolume(volume: Double) { val clamped = volume.coerceIn(0.0, 1.0).toFloat() + lastKnownVolume = clamped DefaultBunnyPlayer.getInstance(context).setVolume(clamped) } /** - * Sets playback speed on the [DefaultBunnyPlayer] singleton directly — - * bypasses the command queue for the same reason as [setVolume]. + * Sets playback speed through the public SDK 4.0.0 `playbackSpeed` property + * on the view. Bypasses the command queue for the same reason as + * [setVolume] — the view is available immediately and the SDK forwards the + * call to the engine. */ fun setPlaybackRate(rate: Double) { if (rate.isFinite() && rate > 0) { - DefaultBunnyPlayer.getInstance(context).setSpeed(rate.toFloat()) + player.playbackSpeed = rate.toFloat() } } + /** Mutes the engine via the public SDK view API. */ + fun mute() { + player.mute() + } + + /** Unmutes the engine via the public SDK view API. */ + fun unmute() { + player.unmute() + } + /** - * Called from the event adapter (plan section 6) when the player reaches - * `STATE_READY`. Drains all pending commands in FIFO order. + * Called from the event adapter when the player reaches `STATE_READY`. + * Drains all pending commands in FIFO order. */ fun onPlayerReady() { commandQueue.setReady(true) } - // --- Sizing / layout (plan section 7) --- + // --- Sizing / layout --- /** * Fabric calls `measure(EXACTLY, EXACTLY)` before `layout`, so the measured * width/height are already the exact pixel dimensions assigned by Yoga. * We forward them unchanged to `setMeasuredDimension`. - * - * Overriding `onMeasure` (rather than relying on the default `FrameLayout` - * implementation) guarantees that the wrapper never applies its own - * `WRAP_CONTENT` or `AT_MOST` logic to the child — the child always receives - * the exact React Native dimensions. */ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val width = MeasureSpec.getSize(widthMeasureSpec) @@ -491,8 +562,6 @@ class BunnyStreamPlayerView( /** * Lays out the single child ([player]) to fill the wrapper exactly. - * Fabric calls `layout()` after `measure()`, so the wrapper's position is - * already set by the framework; we only need to position the child. */ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { if (childCount == 0) return @@ -502,20 +571,11 @@ class BunnyStreamPlayerView( child.layout(0, 0, width, height) } - // --- Cleanup (plan section 8) --- + // --- Cleanup --- /** * Idempotent cleanup. Called from `ViewManager.onDropViewInstance` or from * the lease's [onRevoke] callback when a newer view takes over. - * - * - Detaches the event listener from `DefaultBunnyPlayer.currentPlayer`. - * - Removes the progress listener from the SDK view. - * - Drops all pending commands from the [CommandQueue]. - * - Invalidates all in-flight callbacks via [GenerationToken.bump]. - * - Releases the [BunnyPlayerLease] (no-op if already revoked by a newer view). - * - * The `cleanedUp` guard ensures this runs exactly once even if both - * `onDropViewInstance` and a lease revoke fire. */ fun cleanup() { lease.release() @@ -533,7 +593,15 @@ class BunnyStreamPlayerView( commandQueue.reset() eventListener?.detach() player.setProgressListener(null) + player.autoProgressTextColor = false player.pause() + // Detach SDK callbacks so a reused view (shouldn't happen, but defensively) + // doesn't dispatch into a released emitter. + player.onPlayingChanged = null + player.onMutedChanged = null + player.onPlaybackSpeedChanged = null + player.onVideoSizeChanged = null + player.onPlaybackError = null } companion object { diff --git a/android/src/main/java/net/bunny/reactnative/view/BunnyStreamPlayerViewManager.kt b/android/src/main/java/net/bunny/reactnative/view/BunnyStreamPlayerViewManager.kt index f79b229..5df11df 100644 --- a/android/src/main/java/net/bunny/reactnative/view/BunnyStreamPlayerViewManager.kt +++ b/android/src/main/java/net/bunny/reactnative/view/BunnyStreamPlayerViewManager.kt @@ -109,6 +109,16 @@ class BunnyStreamPlayerViewManager : SimpleViewManager(), view.setPlaybackRate(rate) } + @ReactMethod + override fun mute(view: BunnyStreamPlayerView) { + view.mute() + } + + @ReactMethod + override fun unmute(view: BunnyStreamPlayerView) { + view.unmute() + } + companion object { const val NAME = "BunnyStreamPlayerView" @@ -133,6 +143,10 @@ class BunnyStreamPlayerViewManager : SimpleViewManager(), "topVolumeChange" to mapOf("registrationName" to "onVolumeChange"), "playbackRateChange" to mapOf("registrationName" to "onPlaybackRateChange"), "topPlaybackRateChange" to mapOf("registrationName" to "onPlaybackRateChange"), + "videoSizeChange" to mapOf("registrationName" to "onVideoSizeChange"), + "topVideoSizeChange" to mapOf("registrationName" to "onVideoSizeChange"), + "playbackError" to mapOf("registrationName" to "onPlaybackError"), + "topPlaybackError" to mapOf("registrationName" to "onPlaybackError"), ) } } diff --git a/android/src/main/res/values/styles.xml b/android/src/main/res/values/styles.xml new file mode 100644 index 0000000..78cd94f --- /dev/null +++ b/android/src/main/res/values/styles.xml @@ -0,0 +1,16 @@ + + + +