diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..441594c --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,52 @@ +name: Create release PR +on: + workflow_dispatch: + inputs: + version: + description: 'New version' + required: true + type: string +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + strategy: + matrix: + node-version: [20] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + steps: + - name: Create app token + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.THEOPLAYER_BOT_APP_ID }} + private-key: ${{ secrets.THEOPLAYER_BOT_PRIVATE_KEY }} + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + - name: Configure Git user + run: | + git config user.name 'theoplayer-bot[bot]' + git config user.email '873105+theoplayer-bot[bot]@users.noreply.github.com' + - name: Bump version + shell: bash + run: | + node ./scripts/set_version.js ${{ inputs.version }} + - name: Push to release branch + shell: bash + run: | + git commit -a -m ${{ inputs.version }} + git push origin "HEAD:release/${{ inputs.version }}" + - name: Create pull request + shell: bash + run: | + gh pr create \ + --base main \ + --head "release/${{ inputs.version }}" \ + --title "Release ${{ inputs.version }}" \ + --body "$(node ./scripts/github_changelog.js ${{ inputs.version }})" + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 42df235..fecd023 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,22 +1,39 @@ name: Publish release on: - # Runs whenever a new release is created in GitHub - release: - types: [ created ] + # Runs whenever a release PR is merged + pull_request: + branches: + - main + types: [ closed ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: # Publish job publish: + # Run only for "release/x.y.z" PRs, and only when the PR is merged (not abandoned) + if: ${{ !github.event.pull_request || (startsWith(github.head_ref, 'release/') && github.event.pull_request.merged) }} runs-on: ubuntu-latest # Sets permissions of the GITHUB_TOKEN to allow publishing to GitHub Packages permissions: - contents: read + contents: write packages: write + id-token: write steps: + - name: Create app token + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.THEOPLAYER_BOT_APP_ID }} + private-key: ${{ secrets.THEOPLAYER_BOT_PRIVATE_KEY }} - name: Checkout uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + - name: Configure Git user + run: | + git config user.name 'theoplayer-bot[bot]' + git config user.email '873105+theoplayer-bot[bot]@users.noreply.github.com' - name: Setup Java uses: actions/setup-java@v4 with: @@ -32,3 +49,18 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPOSILITE_USERNAME: ${{ secrets.REPOSILITE_USERNAME }} REPOSILITE_PASSWORD: ${{ secrets.REPOSILITE_PASSWORD }} + - name: Get version + shell: bash + run: | + echo "version=$(./gradlew :ui:properties --no-daemon --console=plain --quiet | awk '/^version:/ {print $2}')" >> "$GITHUB_ENV" + - name: Push tag + run: | + git tag "v$version" -m "$version" + git push origin "v$version" + - name: Create GitHub release + run: | + gh release create "v$version" --verify-tag --latest \ + --title "$version" \ + --notes "$(node ./scripts/github_changelog.js $version)" + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/sync-develop.yml b/.github/workflows/sync-develop.yml new file mode 100644 index 0000000..1c8d03b --- /dev/null +++ b/.github/workflows/sync-develop.yml @@ -0,0 +1,31 @@ +name: Sync develop with main +on: + push: + branches: + - main + workflow_dispatch: +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Create app token + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.THEOPLAYER_BOT_APP_ID }} + private-key: ${{ secrets.THEOPLAYER_BOT_PRIVATE_KEY }} + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + ref: develop + fetch-depth: 50 + - name: Configure Git user + run: | + git config user.name 'theoplayer-bot[bot]' + git config user.email '873105+theoplayer-bot[bot]@users.noreply.github.com' + - name: Sync develop with main + run: | + git fetch --no-tags --prune --no-recurse-submodules --depth=50 origin +refs/heads/main:refs/remotes/origin/main + git merge --ff origin/main + git push origin HEAD:develop diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 0fc3113..4cb7457 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 153c7b6..64de75f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ > - 🏠 Internal > - 💅 Polish +## v1.9.0 (2024-09-10) + +* 💥 Updated to Jetpack Compose version 1.7.0 ([BOM](https://developer.android.com/jetpack/compose/bom) 2024.09.00). +* 💥 Changed `colors` parameter in `IconButton` and `LiveButton` to be an `IconButtonColors`. +* 🚀 Added support for Android Lollipop (API 21), to align with the THEOplayer Android SDK. +* 🚀 Added `rememberPlayer(THEOplayerView)` to create a `Player` wrapping an existing `THEOplayerView`. + ## v1.8.0 (2024-09-06) * 🚀 Added support for THEOplayer 8.0. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 50f952d..d9f99cf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,7 +9,7 @@ android { defaultConfig { applicationId = "com.theoplayer.android.ui.demo" - minSdk = 24 + minSdk = 21 targetSdk = 33 versionCode = 1 versionName = "1.0" @@ -45,7 +45,7 @@ android { compose = true } composeOptions { - kotlinCompilerExtensionVersion = "1.4.3" + kotlinCompilerExtensionVersion = "1.5.15" } packaging { resources { @@ -58,7 +58,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ktx) - implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.compose) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) implementation(libs.androidx.compose.ui.ui) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt b/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt index cc4b606..5127a06 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt @@ -19,11 +19,13 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.google.android.gms.cast.framework.CastContext import com.theoplayer.android.api.THEOplayerConfig +import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.api.ads.ima.GoogleImaIntegrationFactory import com.theoplayer.android.api.cast.CastConfiguration import com.theoplayer.android.api.cast.CastIntegrationFactory @@ -55,22 +57,23 @@ fun MainContent() { var stream by rememberSaveable(stateSaver = StreamSaver) { mutableStateOf(streams.first()) } var streamMenuOpen by remember { mutableStateOf(false) } - val player = rememberPlayer() - LaunchedEffect(player) { - player.theoplayerView?.let { theoplayerView -> + val context = LocalContext.current + val theoplayerView = remember(context) { + THEOplayerView(context).apply { // Add ads integration through Google IMA - theoplayerView.player.addIntegration( - GoogleImaIntegrationFactory.createGoogleImaIntegration(theoplayerView) + player.addIntegration( + GoogleImaIntegrationFactory.createGoogleImaIntegration(this) ) // Add Chromecast integration val castConfiguration = CastConfiguration.Builder().apply { castStrategy(CastStrategy.AUTO) }.build() - theoplayerView.player.addIntegration( - CastIntegrationFactory.createCastIntegration(theoplayerView, castConfiguration) + player.addIntegration( + CastIntegrationFactory.createCastIntegration(this, castConfiguration) ) } } + val player = rememberPlayer(theoplayerView) LaunchedEffect(player, stream) { player.source = stream.source } diff --git a/gradle.properties b/gradle.properties index 9f771bf..9511921 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,4 +24,4 @@ android.nonTransitiveRClass=true android.nonFinalResIds=true org.gradle.configuration-cache=true # The version of the THEOplayer Open Video UI for Android. -libraryVersion=1.8.0 +version=1.9.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4438cf1..565dd90 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,14 +1,14 @@ [versions] -gradle = "8.3.2" -kotlin-gradle-plugin = "1.8.10" +gradle = "8.5.2" +kotlin-gradle-plugin = "1.9.25" ktx = "1.13.1" -lifecycle-runtime = "2.8.4" -activity-compose = "1.9.1" +lifecycle-compose = "2.8.5" +activity-compose = "1.9.2" appcompat = "1.7.0" -compose-bom = "2024.06.00" +compose-bom = "2024.09.00" junit4 = "4.13.2" playServices-castFramework = "21.5.0" -ui-test-junit4 = "1.6.8" # ...not in BOM for some reason? +ui-test-junit4 = "1.7.0" # ...not in BOM for some reason? androidx-junit = "1.2.1" androidx-espresso = "3.6.1" androidx-mediarouter = "1.7.0" @@ -17,7 +17,7 @@ theoplayer = "7.11.0" [libraries] androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" } -androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime" } +androidx-lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-compose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b3f1727..544b5ee 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Nov 20 16:01:06 CET 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/scripts/github_changelog.js b/scripts/github_changelog.js new file mode 100755 index 0000000..fb2b2e5 --- /dev/null +++ b/scripts/github_changelog.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const version = process.argv[2]; +if (!version) { + console.error("Missing required argument: version"); + process.exit(1); +} + +// Find block with current version +const changelogPath = path.resolve(__dirname, "../CHANGELOG.md"); +const changelog = fs.readFileSync(changelogPath, "utf-8"); +const headingStart = "## "; +// Find block with current version +const block = changelog + .split(headingStart) + .find((block) => block.startsWith(`v${version}`)) + .trim(); +let lines = block.split("\n"); +// Remove version +lines.splice(0, 1); + +console.log(lines.join("\n").trim()); diff --git a/scripts/set_version.js b/scripts/set_version.js new file mode 100755 index 0000000..af77b90 --- /dev/null +++ b/scripts/set_version.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const version = process.argv[2]; +if (!version) { + console.error("Missing required argument: version"); + process.exit(1); +} + +// Update "version=1.2.3" in gradle.properties +const gradlePropertiesPath = path.resolve(__dirname, "../gradle.properties"); +let gradleProperties = fs.readFileSync(gradlePropertiesPath, "utf8"); +gradleProperties = gradleProperties.replace( + /^version=.+$/m, + `version=${version}` +); +fs.writeFileSync(gradlePropertiesPath, gradleProperties); + +// Update heading in CHANGELOG.md +const changelogPath = path.resolve(__dirname, "../CHANGELOG.md"); +let changelog = fs.readFileSync(changelogPath, "utf8"); +const now = new Date(); +const today = `${now.getFullYear()}-${(now.getMonth() + 1) + .toString() + .padStart(2, "0")}-${now.getDate().toString().padStart(2, "0")}`; +changelog = changelog.replace( + /^## Unreleased$/m, + `## v${version} (${today})` +); +fs.writeFileSync(changelogPath, changelog); diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index d7b1d12..5063c67 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -23,7 +23,7 @@ android { compileSdk = 34 defaultConfig { - minSdk = 24 + minSdk = 21 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -51,7 +51,7 @@ android { compose = true } composeOptions { - kotlinCompilerExtensionVersion = "1.4.3" + kotlinCompilerExtensionVersion = "1.5.15" } packaging { resources { @@ -71,7 +71,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ktx) - implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.compose) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) implementation(libs.androidx.compose.ui.ui) @@ -123,10 +123,9 @@ publishing { publications { register("release") { - val libraryVersion: String by rootProject.extra groupId = "com.theoplayer.android-ui" artifactId = "android-ui" - version = libraryVersion + version = project.version as String artifact(dokkaJavadocJar) afterEvaluate { from(components["release"]) diff --git a/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt index de7c4fb..edcedd4 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt @@ -25,9 +25,7 @@ import androidx.compose.ui.unit.dp fun ErrorDisplay( modifier: Modifier = Modifier, ) { - val error = Player.current?.error - - error?.let { it -> + Player.current?.error?.let { error -> Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -50,7 +48,7 @@ fun ErrorDisplay( ) } Text( - text = "${it.message}" + text = "${error.message}" ) } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/IconButton.kt b/ui/src/main/java/com/theoplayer/android/ui/IconButton.kt index 2e246e7..eadd5e6 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/IconButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/IconButton.kt @@ -1,19 +1,29 @@ package com.theoplayer.android.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.Interaction import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp /** @@ -40,57 +50,46 @@ fun IconButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, - colors: ButtonColors = IconButtonDefaults.iconButtonColors(), + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), contentPadding: PaddingValues = PaddingValues(0.dp), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, content: @Composable () -> Unit ) { - TextButton( + Box( modifier = modifier + .minimumInteractiveComponentSize() .defaultMinSize( minWidth = IconButtonSize, minHeight = IconButtonSize + ) + .padding(contentPadding) + .clip(CircleShape) + .background(color = colors.containerColor(enabled)) + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = ripple(bounded = false) ), - shape = androidx.compose.material3.IconButtonDefaults.filledShape, - enabled = enabled, - colors = colors, - contentPadding = contentPadding, - interactionSource = interactionSource, - onClick = onClick, - content = { content() } - ) + contentAlignment = Alignment.Center + ) { + val contentColor = colors.contentColor(enabled) + CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + } } -private const val DisabledIconOpacity = 0.38f private val IconButtonSize = 40.dp -/** - * Contains the default values used by icon buttons. - */ -object IconButtonDefaults { - /** - * Creates a [ButtonColors] that represents the default colors used in a [IconButton]. - * - * Equivalent to [androidx.compose.material3.IconButtonDefaults.iconButtonColors], - * but as [ButtonColors] instead of [androidx.compose.material3.IconButtonColors]. - * - * @param containerColor the container color of this icon button when enabled. - * @param contentColor the content color of this icon button when enabled. - * @param disabledContainerColor the container color of this icon button when not enabled. - * @param disabledContentColor the content color of this icon button when not enabled. - */ - @Composable - fun iconButtonColors( - containerColor: Color = Color.Transparent, - contentColor: Color = LocalContentColor.current, - disabledContainerColor: Color = Color.Transparent, - disabledContentColor: Color = contentColor.copy(alpha = DisabledIconOpacity) - ): ButtonColors { - return ButtonDefaults.textButtonColors( - containerColor = containerColor, - contentColor = contentColor, - disabledContainerColor = disabledContainerColor, - disabledContentColor = disabledContentColor - ) - } -} \ No newline at end of file +private fun IconButtonColors.containerColor(enabled: Boolean): Color = + if (enabled) containerColor else disabledContainerColor + +private fun IconButtonColors.contentColor(enabled: Boolean): Color = + if (enabled) contentColor else disabledContentColor + +internal fun IconButtonColors.toButtonColors() = ButtonColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor +) diff --git a/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt b/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt index bc1ff90..ca1d1c3 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt @@ -8,6 +8,8 @@ import androidx.compose.material.icons.rounded.Circle import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -35,7 +37,7 @@ import com.theoplayer.android.ui.theme.THEOplayerTheme fun LiveButton( modifier: Modifier = Modifier, contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding, - colors: ButtonColors = IconButtonDefaults.iconButtonColors(), + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), liveThreshold: Double = 10.0, live: @Composable RowScope.() -> Unit = { Icon( @@ -63,7 +65,7 @@ fun LiveButton( TextButton( modifier = modifier, contentPadding = contentPadding, - colors = colors, + colors = colors.toButtonColors(), onClick = { player.player?.let { it.currentTime = Double.POSITIVE_INFINITY diff --git a/ui/src/main/java/com/theoplayer/android/ui/Player.kt b/ui/src/main/java/com/theoplayer/android/ui/Player.kt index 6b14e26..250d135 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Player.kt @@ -297,6 +297,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player override val player = theoplayerView?.player override val ads = theoplayerView?.player?.ads override var cast by mutableStateOf(null) + private set override var currentTime by mutableStateOf(0.0) private set override var duration by mutableStateOf(Double.NaN) diff --git a/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt b/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt index bc5cb08..d37e4ce 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt @@ -1,15 +1,34 @@ package com.theoplayer.android.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.systemGestureExclusion +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Slider import androidx.compose.material3.SliderColors import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import com.theoplayer.android.api.cast.chromecast.PlayerCastState /** @@ -21,6 +40,7 @@ import com.theoplayer.android.api.cast.chromecast.PlayerCastState * @param colors [SliderColors] that will be used to resolve the colors used for this seek bar in * different states. See [SliderDefaults.colors]. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SeekBar( modifier: Modifier = Modifier, @@ -46,35 +66,105 @@ fun SeekBar( var seekTime by remember { mutableStateOf(null) } var wasPlayingBeforeSeek by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + Slider( modifier = modifier.systemGestureExclusion(), colors = colors, value = seekTime ?: currentTime, valueRange = valueRange, enabled = enabled, - onValueChange = remember { - { time -> - seekTime = time - player?.player?.let { - if (!it.isPaused) { - wasPlayingBeforeSeek = true - it.pause() - } - it.currentTime = time.toDouble() + interactionSource = interactionSource, + thumb = { + SeekBarThumb( + interactionSource = interactionSource, + colors = colors, + enabled = enabled + ) + }, + track = { sliderState -> + SliderDefaults.Track( + modifier = Modifier.height(4.dp), + colors = colors, + enabled = enabled, + sliderState = sliderState, + // Don't draw the stop indicator at the end of the track + drawStopIndicator = {}, + // Remove the gap in the track around the thumb + thumbTrackGapSize = 0.dp + ) + }, + onValueChange = { time -> + seekTime = time + player?.player?.let { + if (!it.isPaused) { + wasPlayingBeforeSeek = true + it.pause() } + it.currentTime = time.toDouble() } }, - // This needs to always be the *same* callback, - // otherwise Slider will reset its internal SliderState while dragging. - // https://github.com/androidx/androidx/blob/4d69c45e6361a2e5af77edc9f7f92af3d0db3877/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt#L270-L282 - onValueChangeFinished = remember { - { - seekTime = null - if (wasPlayingBeforeSeek) { - player?.player?.play() - wasPlayingBeforeSeek = false - } + onValueChangeFinished = { + seekTime = null + if (wasPlayingBeforeSeek) { + player?.player?.play() + wasPlayingBeforeSeek = false } } ) -} \ No newline at end of file +} + +private val ThumbSize = DpSize(20.dp, 20.dp) +private val ThumbDefaultElevation = 1.dp +private val ThumbPressedElevation = 6.dp +private val StateLayerSize = 40.0.dp + +// Slider.Thumb look-and-feel from Compose Material3 version 1.2.1 +// https://github.com/androidx/androidx/blob/d4655d87a9f8dbced1c3c768a595cbfcea505c07/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt#L980 +@Composable +private fun SeekBarThumb( + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + colors: SliderColors = SliderDefaults.colors(), + enabled: Boolean = true, + thumbSize: DpSize = ThumbSize +) { + val interactions = remember { mutableStateListOf() } + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> interactions.add(interaction) + is PressInteraction.Release -> interactions.remove(interaction.press) + is PressInteraction.Cancel -> interactions.remove(interaction.press) + is DragInteraction.Start -> interactions.add(interaction) + is DragInteraction.Stop -> interactions.remove(interaction.start) + is DragInteraction.Cancel -> interactions.remove(interaction.start) + } + } + } + + val elevation = if (interactions.isNotEmpty()) { + ThumbPressedElevation + } else { + ThumbDefaultElevation + } + val shape = CircleShape + + Spacer( + modifier + .size(thumbSize) + .indication( + interactionSource = interactionSource, + indication = ripple( + bounded = false, + radius = StateLayerSize / 2 + ) + ) + .hoverable(interactionSource = interactionSource) + .shadow(if (enabled) elevation else 0.dp, shape, clip = false) + .background(colors.thumbColor(enabled), shape) + ) +} + +private fun SliderColors.thumbColor(enabled: Boolean): Color = + if (enabled) thumbColor else disabledThumbColor \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt index 8674b45..209d77f 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt @@ -57,8 +57,8 @@ fun SeekButton( ) Text( modifier = Modifier - .align(Alignment.TopCenter) - .offset(y = iconSize * 0.4f), + .align(Alignment.Center) + .offset(y = iconSize * 0.1f), text = "${seekOffset.absoluteValue}", fontSize = 6.sp * (iconSize / 24.dp) ) diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 1f78ef1..7385215 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import android.app.Activity import android.view.ViewGroup import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -41,11 +42,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import com.theoplayer.android.api.THEOplayerConfig import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.api.cast.chromecast.PlayerCastState @@ -453,36 +454,75 @@ fun rememberPlayer(config: THEOplayerConfig? = null): Player { val theoplayerView = if (LocalInspectionMode.current) { null } else { - rememberTHEOplayerView(config) + val context = LocalContext.current + remember { THEOplayerView(context, config) } } - val player = remember(theoplayerView) { PlayerImpl(theoplayerView) } - DisposableEffect(player) { + DisposableEffect(theoplayerView) { onDispose { - player.dispose() + theoplayerView?.onDestroy() } } - return player + return rememberPlayerInternal(theoplayerView) } /** - * Creates and remembers a THEOplayer view. + * Create a [Player] wrapping an existing [THEOplayerView]. * - * @param config the player configuration + * The [THEOplayerView] should be [remembered][remember] so it's not re-created on every + * recomposition. + * + * Example usage: + * ```kotlin + * val context = LocalContext.current + * val theoplayerView = remember(context) { + * val config = THEOplayerConfig.Builder().build() + * THEOplayerView(context, config) + * } + * val player = rememberPlayer(theoplayerView) + * ``` + * + * This couples the lifecycle of the given [THEOplayerView] to the current activity. + * That is, it automatically calls [THEOplayerView.onPause] and [THEOplayerView.onResume] + * whenever the current activity is [paused][Activity.onPause] or [resumed][Activity.onResume]. + * + * The [THEOplayerView] is **not** automatically destroyed when the composition is disposed. + * If you need this, use a [DisposableEffect]: + * ```kotlin + * val player = rememberPlayer(theoplayerView) + * DisposableEffect(theoplayerView) { + * onDispose { + * theoplayerView.onDestroy() + * } + * } + * ``` + * + * @param theoplayerView the existing THEOplayer view */ @Composable -internal fun rememberTHEOplayerView(config: THEOplayerConfig? = null): THEOplayerView { - val context = LocalContext.current - val theoplayerView = remember { THEOplayerView(context, config) } - var wasPlayingAd by remember { mutableStateOf(false) } +fun rememberPlayer(theoplayerView: THEOplayerView): Player { + return rememberPlayerInternal(theoplayerView) +} - DisposableEffect(theoplayerView) { +@Composable +internal fun rememberPlayerInternal(theoplayerView: THEOplayerView?): Player { + theoplayerView?.let { setupTHEOplayerView(it) } + + val player = remember(theoplayerView) { PlayerImpl(theoplayerView) } + DisposableEffect(player) { onDispose { - theoplayerView.onDestroy() + player.dispose() } } + return player +} + +@Composable +internal fun setupTHEOplayerView(theoplayerView: THEOplayerView): THEOplayerView { + var wasPlayingAd by remember { mutableStateOf(false) } + val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle, theoplayerView) { val lifecycleObserver = LifecycleEventObserver { _, event ->