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 ->