Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* 🚀 Pressing a key now shows the controls in `UIController`, which helps on tablets with a keyboard. Media keys (play/pause, fast-forward and rewind) control playback, even while the controls are hidden. ([#122](https://github.com/THEOplayer/android-ui/pull/122))
* 🚀 Pressing Back now closes an open menu in `UIController`. ([#124](https://github.com/THEOplayer/android-ui/pull/124))
* 🚀 Added `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#124](https://github.com/THEOplayer/android-ui/pull/124))
* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124))
* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124), [#125](https://github.com/THEOplayer/android-ui/pull/125))

## [1.14.2] (2026-10-13)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.theoplayer.android.ui

import kotlin.math.absoluteValue

/**
* Formats a playback time as a human-readable string.
*/
@InternalUiApi
fun formatPlaybackTime(
time: Double,
guide: Double = 0.0,
preferNegative: Boolean = false
): String {
val isNegative = time < 0 || (preferNegative && time == 0.0)
val absoluteTime = time.absoluteValue.toInt()

val guideMinutes = ((guide / 60) % 60).toInt()
val guideHours = (guide / 3600).toInt()

val result = StringBuilder()
if (isNegative) {
result.append('-')
}
if (time.isNaN() || time.isInfinite()) {
if (guideHours > 0) {
result.append("--:")
}
result.append("--:--")
} else {
val seconds = absoluteTime % 60
val minutes = (absoluteTime / 60) % 60
val hours = absoluteTime / 3600
// Check if we need to show hours.
val showHours = hours > 0 || guideHours > 0
if (showHours) {
result.append(hours)
result.append(':')
}
// If hours are showing, we may need to add a leading zero to minutes.
// Always show at least one digit of minutes.
val showMinutesLeadingZero = (showHours || guideMinutes >= 0)
if (showMinutesLeadingZero) {
result.append(minutes.toString().padStart(2, '0'))
} else {
result.append(minutes)
}
result.append(':')
result.append(seconds.toString().padStart(2, '0'))
}
return result.toString()
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@ class SeekBarState internal constructor(private val player: Player?) {
}
}

/**
* Seeks to a position within [valueRange] without changing playback state.
*/
fun seekTo(value: Float) {
if (!enabled) return
player?.currentTime = seekableRange.start + value.toDouble()
}

/**
* Called when the user stops dragging the seek bar. Resumes playback if the player was
* playing before the drag started.
Expand Down
126 changes: 126 additions & 0 deletions ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.theoplayer.android.ui.theme

import androidx.compose.ui.graphics.Color
import com.theoplayer.android.ui.InternalUiApi

@InternalUiApi
val md_theme_light_primary = Color(0xFF775A00)
@InternalUiApi
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
@InternalUiApi
val md_theme_light_primaryContainer = Color(0xFFFFDF98)
@InternalUiApi
val md_theme_light_onPrimaryContainer = Color(0xFF251A00)
@InternalUiApi
val md_theme_light_secondary = Color(0xFF6A5D3F)
@InternalUiApi
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
@InternalUiApi
val md_theme_light_secondaryContainer = Color(0xFFF4E0BB)
@InternalUiApi
val md_theme_light_onSecondaryContainer = Color(0xFF241A04)
@InternalUiApi
val md_theme_light_tertiary = Color(0xFF496548)
@InternalUiApi
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
@InternalUiApi
val md_theme_light_tertiaryContainer = Color(0xFFCBEBC6)
@InternalUiApi
val md_theme_light_onTertiaryContainer = Color(0xFF06210A)
@InternalUiApi
val md_theme_light_error = Color(0xFFBA1A1A)
@InternalUiApi
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
@InternalUiApi
val md_theme_light_onError = Color(0xFFFFFFFF)
@InternalUiApi
val md_theme_light_onErrorContainer = Color(0xFF410002)
@InternalUiApi
val md_theme_light_background = Color(0xFFFFFBFF)
@InternalUiApi
val md_theme_light_onBackground = Color(0xFF1E1B16)
@InternalUiApi
val md_theme_light_surface = Color(0xFFFFFBFF)
@InternalUiApi
val md_theme_light_onSurface = Color(0xFF1E1B16)
@InternalUiApi
val md_theme_light_surfaceVariant = Color(0xFFECE1CF)
@InternalUiApi
val md_theme_light_onSurfaceVariant = Color(0xFF4D4639)
@InternalUiApi
val md_theme_light_outline = Color(0xFF7E7667)
@InternalUiApi
val md_theme_light_inverseOnSurface = Color(0xFFF7F0E7)
@InternalUiApi
val md_theme_light_inverseSurface = Color(0xFF33302A)
@InternalUiApi
val md_theme_light_inversePrimary = Color(0xFFFFC50F)
@InternalUiApi
val md_theme_light_shadow = Color(0xFF000000)
@InternalUiApi
val md_theme_light_surfaceTint = Color(0xFF775A00)
@InternalUiApi
val md_theme_light_outlineVariant = Color(0xFFD0C5B4)
@InternalUiApi
val md_theme_light_scrim = Color(0xFF000000)

@InternalUiApi
val md_theme_dark_primary = Color(0xFFFFC50F)
@InternalUiApi
val md_theme_dark_onPrimary = Color(0xFF3F2E00)
@InternalUiApi
val md_theme_dark_primaryContainer = Color(0xFF5A4300)
@InternalUiApi
val md_theme_dark_onPrimaryContainer = Color(0xFFFFDF98)
@InternalUiApi
val md_theme_dark_secondary = Color(0xFFD7C5A0)
@InternalUiApi
val md_theme_dark_onSecondary = Color(0xFF3A2F15)
@InternalUiApi
val md_theme_dark_secondaryContainer = Color(0xFF52452A)
@InternalUiApi
val md_theme_dark_onSecondaryContainer = Color(0xFFF4E0BB)
@InternalUiApi
val md_theme_dark_tertiary = Color(0xFFAFCFAB)
@InternalUiApi
val md_theme_dark_onTertiary = Color(0xFF1C361D)
@InternalUiApi
val md_theme_dark_tertiaryContainer = Color(0xFF324D32)
@InternalUiApi
val md_theme_dark_onTertiaryContainer = Color(0xFFCBEBC6)
@InternalUiApi
val md_theme_dark_error = Color(0xFFFFB4AB)
@InternalUiApi
val md_theme_dark_errorContainer = Color(0xFF93000A)
@InternalUiApi
val md_theme_dark_onError = Color(0xFF690005)
@InternalUiApi
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
@InternalUiApi
val md_theme_dark_background = Color(0xFF1E1B16)
@InternalUiApi
val md_theme_dark_onBackground = Color(0xFFE9E1D9)
@InternalUiApi
val md_theme_dark_surface = Color(0xFF1E1B16)
@InternalUiApi
val md_theme_dark_onSurface = Color(0xFFE9E1D9)
@InternalUiApi
val md_theme_dark_surfaceVariant = Color(0xFF4D4639)
@InternalUiApi
val md_theme_dark_onSurfaceVariant = Color(0xFFD0C5B4)
@InternalUiApi
val md_theme_dark_outline = Color(0xFF999080)
@InternalUiApi
val md_theme_dark_inverseOnSurface = Color(0xFF1E1B16)
@InternalUiApi
val md_theme_dark_inverseSurface = Color(0xFFE9E1D9)
@InternalUiApi
val md_theme_dark_inversePrimary = Color(0xFF775A00)
@InternalUiApi
val md_theme_dark_shadow = Color(0xFF000000)
@InternalUiApi
val md_theme_dark_surfaceTint = Color(0xFFFFC50F)
@InternalUiApi
val md_theme_dark_outlineVariant = Color(0xFF4D4639)
@InternalUiApi
val md_theme_dark_scrim = Color(0xFF000000)
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.theoplayer.android.ui.theme

import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import com.theoplayer.android.ui.InternalUiApi

/**
* Additional colors used by THEOplayer UI components.
*/
@Immutable
data class PlayerColors(
/**
* The color of the circle icon of a live button when playing at the live point.
*/
val liveButtonLive: Color = Color.Red,
/**
* The color of the circle icon of a live button when playing behind the live point.
*/
val liveButtonDvr: Color = Color.Gray
)

/**
* Player component colors shared by UI modules.
*/
@InternalUiApi
val LocalPlayerColors = staticCompositionLocalOf { PlayerColors() }
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,30 @@ class SeekBarStateTest {
verify(exactly = 1) { mock.player.play() }
}

@Test
fun `seekTo seeks without changing playback state`() {
val mock = MockPlayer(paused = false, currentTime = 50.0)
val state = SeekBarState(mock.player)

state.seekTo(60f)

verify { mock.player.currentTime = 60.0 }
verify(exactly = 0) { mock.player.pause() }
verify(exactly = 0) { mock.player.play() }
}

@Test
fun `seekTo does nothing when disabled or player is missing`() {
val mock = MockPlayer(seekableRanges = emptyList())

SeekBarState(mock.player).seekTo(60f)
SeekBarState(null).seekTo(60f)

verify(exactly = 0) { mock.player.currentTime = any() }
verify(exactly = 0) { mock.player.pause() }
verify(exactly = 0) { mock.player.play() }
}

@Test
fun `scrubbing a paused player does not resume it`() = runTest {
val mock = MockPlayer(paused = true, currentTime = 10.0)
Expand Down
1 change: 1 addition & 0 deletions ui-tv/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
1 change: 1 addition & 0 deletions ui-tv/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ dependencies {
implementation(libs.androidx.tv.material)
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.ui.ui)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.ui.toolingPreview)
Expand Down
Loading
Loading