Compare commits
18
Commits
85da03360d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21f347e588 | ||
|
|
4dd07f84e7 | ||
|
|
0dfb3788e8 | ||
|
|
dd1c62f0f0 | ||
|
|
391797d377 | ||
|
|
e3e8120e8b | ||
|
|
29601803f5 | ||
|
|
614538dc98 | ||
|
|
84d4914d8a | ||
|
|
702e9810d0 | ||
|
|
7c991c04be | ||
|
|
807445eafa | ||
|
|
d7726424d9 | ||
|
|
fdf14f7dd2 | ||
|
|
ea6a751e72 | ||
|
|
0bb52f3ec8 | ||
|
|
34e2ef6bd7 | ||
|
|
ebd6963bf5 |
No files matched your search
@@ -23,8 +23,8 @@ android {
|
||||
applicationId = "com.example.myapplication"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
versionCode = 2
|
||||
versionName = "1.0.1"
|
||||
buildConfigField("String", "TMDB_ACCESS_TOKEN", "\"${tmdbAccessToken.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ dependencies {
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.example.myapplication.data.FavoriteRepository
|
||||
import com.example.myapplication.data.IconLibraryRepository
|
||||
import com.example.myapplication.data.PlayerSettings
|
||||
import com.example.myapplication.data.ServerRepository
|
||||
import com.example.myapplication.data.WatchlistRepository
|
||||
import com.example.myapplication.ui.AppLanguage
|
||||
@@ -24,6 +25,7 @@ class MainActivity : ComponentActivity() {
|
||||
// These are fast SharedPreferences/DB singleton inits — safe on main thread
|
||||
AppLanguage.init(this)
|
||||
AppTheme.init(this)
|
||||
PlayerSettings.init(this)
|
||||
IconLibraryRepository.init(this)
|
||||
ServerRepository.init(this)
|
||||
FavoriteRepository.init(this)
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
package com.example.myapplication
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -30,6 +25,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -301,22 +297,28 @@ fun MainApp() {
|
||||
} // close inner CompositionLocalProvider
|
||||
} // close inner Box
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showBottomBar,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
enter = if (returningFromSettingsChild) {
|
||||
EnterTransition.None
|
||||
} else {
|
||||
fadeIn(tween(180)) + slideInVertically(tween(180)) { it / 3 }
|
||||
},
|
||||
exit = if (isSettingsChild) {
|
||||
ExitTransition.None
|
||||
} else {
|
||||
fadeOut(tween(180)) + slideOutVertically(tween(180)) { it / 3 }
|
||||
val bottomBarProgress = animateFloatAsState(
|
||||
targetValue = if (showBottomBar) 1f else 0f,
|
||||
animationSpec = if (returningFromSettingsChild || isSettingsChild) snap() else tween(180),
|
||||
label = "bottomBarVisibility",
|
||||
)
|
||||
// Keep icons, gesture animations and glass layers alive across
|
||||
// detail pages. Unplaced children neither draw nor receive input.
|
||||
Box(
|
||||
modifier = Modifier.align(Alignment.BottomCenter).layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height) {
|
||||
if (bottomBarProgress.value > 0f) {
|
||||
placeable.placeWithLayer(0, 0) {
|
||||
alpha = bottomBarProgress.value
|
||||
translationY = (1f - alpha) * size.height / 3f
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
LiquidAppBottomBar(
|
||||
|
||||
@@ -295,7 +295,7 @@ object EmbyRepository {
|
||||
limit: Int = 6,
|
||||
): EmbyLibraryPage = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = "${server.fullUrl}/emby/Users/${server.userId}/Items?ParentId=$libraryId&SortBy=$sortBy&SortOrder=$sortOrder&StartIndex=${startIndex.coerceAtLeast(0)}&Limit=${limit.coerceAtLeast(1)}&IncludeItemTypes=Movie,Series&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,Genres,RecursiveChildCount,ChildCount,DateCreated,RunTimeTicks,UserData&Recursive=true"
|
||||
val url = "${server.fullUrl}/emby/Users/${server.userId}/Items?ParentId=$libraryId&SortBy=$sortBy&SortOrder=$sortOrder&StartIndex=${startIndex.coerceAtLeast(0)}&Limit=${limit.coerceAtLeast(1)}&IncludeItemTypes=Movie,Series&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,Genres,RecursiveChildCount,ChildCount,DateCreated,RunTimeTicks,UserData,CommunityRating,OfficialRating&Recursive=true"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("X-Emby-Token", server.apiKey)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.example.myapplication.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
object PlayerSettings {
|
||||
private const val PREF_NAME = "player_settings"
|
||||
|
||||
// Keys
|
||||
private const val KEY_DEFAULT_SPEED = "default_speed"
|
||||
private const val KEY_LONG_PRESS_SPEED = "long_press_speed"
|
||||
private const val KEY_SEEK_SECONDS = "seek_seconds"
|
||||
private const val KEY_AUTO_SKIP_INTRO = "auto_skip_intro"
|
||||
private const val KEY_AUTO_SKIP_OUTRO = "auto_skip_outro"
|
||||
private const val KEY_CONFIRM_CONTINUOUS = "confirm_before_continuous"
|
||||
private const val KEY_PROGRESS_REPORT_INTERVAL = "progress_report_interval_ms"
|
||||
|
||||
// In-memory state
|
||||
var defaultSpeed by mutableFloatStateOf(1.0f)
|
||||
var longPressSpeed by mutableFloatStateOf(2.0f)
|
||||
var seekSeconds by mutableIntStateOf(10)
|
||||
var autoSkipIntro by mutableStateOf(false)
|
||||
var autoSkipOutro by mutableStateOf(false)
|
||||
var confirmBeforeContinuous by mutableStateOf(true)
|
||||
var progressReportIntervalMs by mutableLongStateOf(10_000L)
|
||||
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
prefs?.let { p ->
|
||||
defaultSpeed = p.getFloat(KEY_DEFAULT_SPEED, 1.0f)
|
||||
longPressSpeed = p.getFloat(KEY_LONG_PRESS_SPEED, 2.0f)
|
||||
seekSeconds = p.getInt(KEY_SEEK_SECONDS, 10)
|
||||
autoSkipIntro = p.getBoolean(KEY_AUTO_SKIP_INTRO, false)
|
||||
autoSkipOutro = p.getBoolean(KEY_AUTO_SKIP_OUTRO, false)
|
||||
confirmBeforeContinuous = p.getBoolean(KEY_CONFIRM_CONTINUOUS, true)
|
||||
progressReportIntervalMs = p.getLong(KEY_PROGRESS_REPORT_INTERVAL, 10_000L)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDefaultSpeed(value: Float) {
|
||||
defaultSpeed = value
|
||||
prefs?.edit()?.putFloat(KEY_DEFAULT_SPEED, value)?.apply()
|
||||
}
|
||||
|
||||
fun updateLongPressSpeed(value: Float) {
|
||||
longPressSpeed = value
|
||||
prefs?.edit()?.putFloat(KEY_LONG_PRESS_SPEED, value)?.apply()
|
||||
}
|
||||
|
||||
fun updateSeekSeconds(value: Int) {
|
||||
seekSeconds = value
|
||||
prefs?.edit()?.putInt(KEY_SEEK_SECONDS, value)?.apply()
|
||||
}
|
||||
|
||||
fun updateAutoSkipIntro(value: Boolean) {
|
||||
autoSkipIntro = value
|
||||
prefs?.edit()?.putBoolean(KEY_AUTO_SKIP_INTRO, value)?.apply()
|
||||
}
|
||||
|
||||
fun updateAutoSkipOutro(value: Boolean) {
|
||||
autoSkipOutro = value
|
||||
prefs?.edit()?.putBoolean(KEY_AUTO_SKIP_OUTRO, value)?.apply()
|
||||
}
|
||||
|
||||
fun updateConfirmBeforeContinuous(value: Boolean) {
|
||||
confirmBeforeContinuous = value
|
||||
prefs?.edit()?.putBoolean(KEY_CONFIRM_CONTINUOUS, value)?.apply()
|
||||
}
|
||||
|
||||
fun updateProgressReportIntervalMs(value: Long) {
|
||||
progressReportIntervalMs = value
|
||||
prefs?.edit()?.putLong(KEY_PROGRESS_REPORT_INTERVAL, value)?.apply()
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import com.example.myapplication.data.EmbyItem
|
||||
@@ -35,6 +37,7 @@ import com.example.myapplication.R as Res
|
||||
import com.example.myapplication.screens.AboutScreen
|
||||
import com.example.myapplication.screens.AddServerScreen
|
||||
import com.example.myapplication.screens.CacheManagementScreen
|
||||
import com.example.myapplication.screens.PlaybackSettingsScreen
|
||||
import com.example.myapplication.screens.HomeScreen
|
||||
import com.example.myapplication.screens.HomeLibraryAllScreen
|
||||
import com.example.myapplication.screens.IconLibraryManagerScreen
|
||||
@@ -55,6 +58,8 @@ import com.example.myapplication.ui.design.LumaColors
|
||||
import com.example.myapplication.ui.design.rememberLumaLayerBackdrop
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@@ -78,6 +83,21 @@ fun AppNavHost(
|
||||
onDeleteIconLibrary: (String) -> Unit,
|
||||
onRefreshIconLibrary: (String) -> Unit,
|
||||
) {
|
||||
// NavHost uses separate callbacks while seeking a gesture and after release.
|
||||
// Keep the same geometry until the source entry finishes its cancel animation.
|
||||
val backMotion = remember { PredictiveBackMotion() }
|
||||
val currentEntry by navController.currentBackStackEntryAsState()
|
||||
DisposableEffect(currentEntry) {
|
||||
val entry = currentEntry
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && entry != null) {
|
||||
backMotion.onEntryResumed(entry.id)
|
||||
}
|
||||
}
|
||||
entry?.lifecycle?.addObserver(observer)
|
||||
onDispose { entry?.lifecycle?.removeObserver(observer) }
|
||||
}
|
||||
|
||||
fun navigateOnce(route: String) {
|
||||
if (navController.currentDestination?.route == route) return
|
||||
navController.navigate(route) { launchSingleTop = true }
|
||||
@@ -115,10 +135,20 @@ fun AppNavHost(
|
||||
navController = navController,
|
||||
startDestination = AppRoute.Home.pattern,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enterTransition = { appEnterTransition() },
|
||||
exitTransition = { appExitTransition() },
|
||||
enterTransition = {
|
||||
if (backMotion.directionFor(initialState.id) != null) appPopEnterTransition()
|
||||
else appEnterTransition()
|
||||
},
|
||||
exitTransition = {
|
||||
val direction = backMotion.directionFor(initialState.id)
|
||||
if (direction != null) appPopExitTransition(direction) else appExitTransition()
|
||||
},
|
||||
popEnterTransition = { appPopEnterTransition() },
|
||||
popExitTransition = { appPopExitTransition() },
|
||||
popExitTransition = { appPopExitTransition(backMotion.directionFor(initialState.id)) },
|
||||
predictivePopEnterTransition = { appPopEnterTransition() },
|
||||
predictivePopExitTransition = { swipeEdge ->
|
||||
appPopExitTransition(backMotion.start(initialState.id, swipeEdge))
|
||||
},
|
||||
) {
|
||||
composable(AppRoute.Home.pattern) {
|
||||
// P1: Remember lambdas passed to screen composables
|
||||
@@ -194,13 +224,14 @@ fun AppNavHost(
|
||||
}
|
||||
|
||||
composable(AppRoute.Settings.pattern) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().background(LumaColors.background)) {
|
||||
SettingsTopBar()
|
||||
Box(Modifier.weight(1f)) {
|
||||
SettingsScreen(
|
||||
servers = servers,
|
||||
onOpenCacheDetail = { navigateOnce(AppRoute.CacheManagement.pattern) },
|
||||
onOpenAbout = { navigateOnce(AppRoute.About.pattern) },
|
||||
onOpenPlaybackSettings = { navigateOnce(AppRoute.PlaybackSettings.pattern) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -208,7 +239,7 @@ fun AppNavHost(
|
||||
|
||||
composable(AppRoute.Search.pattern) {
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().background(LumaColors.background)) {
|
||||
SearchTopBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
@@ -343,6 +374,10 @@ fun AppNavHost(
|
||||
CacheManagementScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(AppRoute.PlaybackSettings.pattern) {
|
||||
PlaybackSettingsScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(AppRoute.About.pattern) {
|
||||
AboutScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
@@ -393,16 +428,18 @@ private fun MediaDetailDestination(
|
||||
loading = false
|
||||
}
|
||||
|
||||
when {
|
||||
item != null -> MediaDetailScreen(item = item!!, server = server, servers = servers, onClose = onClose, onPlay = onPlay)
|
||||
loading -> DestinationLoading()
|
||||
loadError -> LumaErrorState(
|
||||
title = stringResource(Res.string.detail_load_failed),
|
||||
description = stringResource(Res.string.error_content_sub),
|
||||
retryLabel = stringResource(Res.string.retry),
|
||||
onRetry = { retryToken++ },
|
||||
)
|
||||
else -> DestinationMessage(stringResource(Res.string.detail_load_failed), background = Color.Transparent)
|
||||
Box(Modifier.fillMaxSize().background(LumaColors.background)) {
|
||||
when {
|
||||
item != null -> MediaDetailScreen(item = item!!, server = server, servers = servers, onClose = onClose, onPlay = onPlay)
|
||||
loading -> DestinationLoading()
|
||||
loadError -> LumaErrorState(
|
||||
title = stringResource(Res.string.detail_load_failed),
|
||||
description = stringResource(Res.string.error_content_sub),
|
||||
retryLabel = stringResource(Res.string.retry),
|
||||
onRetry = { retryToken++ },
|
||||
)
|
||||
else -> DestinationMessage(stringResource(Res.string.detail_load_failed), background = Color.Transparent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +478,23 @@ private fun PlayerDestination(
|
||||
itemLoading = false
|
||||
}
|
||||
|
||||
// Fetch series logo if episode has no logo
|
||||
val currentSeriesId = seriesId ?: item?.seriesId
|
||||
LaunchedEffect(server?.id, currentSeriesId, item?.id) {
|
||||
if (item != null && item!!.logoImageTag == null && !currentSeriesId.isNullOrBlank() && server != null && server.apiKey.isNotBlank()) {
|
||||
try {
|
||||
val seriesItem = EmbyRepository.getItem(server, currentSeriesId)
|
||||
if (seriesItem?.logoImageTag != null) {
|
||||
val updated = item!!.copy(
|
||||
imageTags = (item!!.imageTags ?: emptyMap()) + mapOf("Logo" to seriesItem.logoImageTag!!)
|
||||
)
|
||||
item = updated
|
||||
selectedItem = updated
|
||||
}
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
val effectiveSeriesId = seriesId ?: item?.seriesId
|
||||
val effectiveSeasonId = seasonId ?: item?.seasonId ?: item?.parentId
|
||||
LaunchedEffect(server?.id, effectiveSeriesId, effectiveSeasonId) {
|
||||
@@ -482,10 +536,25 @@ private fun PlayerDestination(
|
||||
if (playbackUrl.isNullOrBlank()) playbackError = playbackUrlFailedMessage
|
||||
}
|
||||
|
||||
// Compute logo URL: for episodes, logo is on the series item, not the episode
|
||||
val logoUrl = target?.let { t ->
|
||||
val serverUrl = server?.fullUrl.orEmpty()
|
||||
if (serverUrl.isBlank()) null
|
||||
else {
|
||||
val logoTag = t.logoImageTag
|
||||
if (logoTag != null) {
|
||||
// Use series ID for episode logos (logo lives on series, not episode)
|
||||
val logoItemId = if (t.seriesId != null && t.id != t.seriesId) t.seriesId else t.id
|
||||
"$serverUrl/emby/Items/$logoItemId/Images/Logo?tag=$logoTag&maxWidth=600&quality=90"
|
||||
} else null
|
||||
}
|
||||
}.orEmpty()
|
||||
|
||||
when {
|
||||
target != null -> VideoPlayerScreen(
|
||||
title = target.name,
|
||||
titleLogoUrl = target.getLogoUrl(server?.fullUrl.orEmpty()).orEmpty(),
|
||||
seriesName = target.seriesName.orEmpty(),
|
||||
titleLogoUrl = logoUrl,
|
||||
serverName = server?.name.orEmpty(),
|
||||
serverIconUrl = server?.iconUrl.orEmpty(),
|
||||
serverUrl = server?.fullUrl.orEmpty(),
|
||||
|
||||
@@ -21,6 +21,7 @@ sealed interface AppRoute {
|
||||
}
|
||||
data object IconLibraryManager : AppRoute { override val pattern = "library/icons" }
|
||||
data object CacheManagement : AppRoute { override val pattern = "settings/cache" }
|
||||
data object PlaybackSettings : AppRoute { override val pattern = "settings/playback" }
|
||||
data object About : AppRoute { override val pattern = "settings/about" }
|
||||
data object ServerEditor : AppRoute {
|
||||
override val pattern = "library/server/{serverId}"
|
||||
|
||||
@@ -7,21 +7,19 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import com.example.myapplication.ui.design.LumaMotion
|
||||
|
||||
object NavigationMotionTokens {
|
||||
const val detailPushDuration = LumaMotion.page
|
||||
const val detailPopDuration = LumaMotion.pagePop
|
||||
const val tabDuration = LumaMotion.standard
|
||||
const val detailParallax = 0.20f
|
||||
const val tabOffset = 0.25f
|
||||
const val detailPushDuration = LumaMotion.standard
|
||||
const val detailPopDuration = LumaMotion.dialog
|
||||
const val predictivePopDuration = LumaMotion.slow
|
||||
const val tabDuration = LumaMotion.menu
|
||||
|
||||
val pushEasing = LumaMotion.enter
|
||||
val popEasing = LumaMotion.exit
|
||||
val tabEasing = LumaMotion.native
|
||||
val popEasing = LumaMotion.native
|
||||
}
|
||||
|
||||
fun AnimatedContentTransitionScope<NavBackStackEntry>.appEnterTransition(): EnterTransition {
|
||||
@@ -30,64 +28,43 @@ fun AnimatedContentTransitionScope<NavBackStackEntry>.appEnterTransition(): Ente
|
||||
val initialTab = rootTabIndex(initialRoute)
|
||||
val targetTab = rootTabIndex(targetRoute)
|
||||
return if (initialTab != null && targetTab != null) {
|
||||
val direction = if (targetTab >= initialTab) 1 else -1
|
||||
slideInHorizontally(
|
||||
animationSpec = tween(NavigationMotionTokens.tabDuration, easing = NavigationMotionTokens.tabEasing),
|
||||
) { width -> direction * (width * NavigationMotionTokens.tabOffset).toInt() } +
|
||||
fadeIn(tween(NavigationMotionTokens.tabDuration))
|
||||
// Root tabs replace the current section; avoid moving two media grids
|
||||
// through the viewport when the user switches tabs rapidly.
|
||||
fadeIn(tween(NavigationMotionTokens.tabDuration))
|
||||
} else {
|
||||
slideInHorizontally(
|
||||
animationSpec = tween(NavigationMotionTokens.detailPushDuration, easing = NavigationMotionTokens.pushEasing),
|
||||
) { width -> width } +
|
||||
fadeIn(tween(220))
|
||||
) { width -> width }
|
||||
}
|
||||
}
|
||||
|
||||
fun AnimatedContentTransitionScope<NavBackStackEntry>.appExitTransition(): ExitTransition {
|
||||
val initialRoute = initialState.destination.route
|
||||
val targetRoute = targetState.destination.route
|
||||
val initialTab = rootTabIndex(initialRoute)
|
||||
val targetTab = rootTabIndex(targetRoute)
|
||||
return if (initialTab != null && targetTab != null) {
|
||||
val direction = if (targetTab >= initialTab) 1 else -1
|
||||
slideOutHorizontally(
|
||||
animationSpec = tween(NavigationMotionTokens.tabDuration, easing = NavigationMotionTokens.tabEasing),
|
||||
) { width -> -direction * (width * NavigationMotionTokens.tabOffset).toInt() } +
|
||||
fadeOut(tween(190))
|
||||
} else {
|
||||
slideOutHorizontally(
|
||||
animationSpec = tween(NavigationMotionTokens.detailPushDuration, easing = NavigationMotionTokens.pushEasing),
|
||||
) { width -> -(width * NavigationMotionTokens.detailParallax).toInt() } +
|
||||
fadeOut(tween(220))
|
||||
}
|
||||
// Keep the covered page static until the incoming page finishes entering.
|
||||
return ExitTransition.KeepUntilTransitionsFinished
|
||||
}
|
||||
|
||||
fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopEnterTransition(): EnterTransition {
|
||||
if (isRootRoute(initialState.destination.route) && isRootRoute(targetState.destination.route)) {
|
||||
return appEnterTransition()
|
||||
}
|
||||
if (isReturningFromSettingsChild()) return EnterTransition.None
|
||||
return slideInHorizontally(
|
||||
animationSpec = tween(NavigationMotionTokens.detailPopDuration, easing = NavigationMotionTokens.popEasing),
|
||||
) { width -> -(width * NavigationMotionTokens.detailParallax).toInt() } +
|
||||
fadeIn(tween(220))
|
||||
return EnterTransition.None
|
||||
}
|
||||
|
||||
fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopExitTransition(): ExitTransition {
|
||||
if (isRootRoute(initialState.destination.route) && isRootRoute(targetState.destination.route)) {
|
||||
return appExitTransition()
|
||||
fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopExitTransition(
|
||||
gestureDirection: Int? = null,
|
||||
): ExitTransition {
|
||||
val initialTab = rootTabIndex(initialState.destination.route)
|
||||
val targetTab = rootTabIndex(targetState.destination.route)
|
||||
if (initialTab != null && targetTab != null) {
|
||||
return fadeOut(
|
||||
if (gestureDirection != null) tween(NavigationMotionTokens.tabDuration, easing = LinearEasing)
|
||||
else tween(NavigationMotionTokens.tabDuration),
|
||||
)
|
||||
}
|
||||
if (isReturningFromSettingsChild()) return ExitTransition.None
|
||||
return slideOutHorizontally(
|
||||
animationSpec = tween(NavigationMotionTokens.detailPopDuration, easing = NavigationMotionTokens.popEasing),
|
||||
) { width -> width } +
|
||||
fadeOut(tween(220))
|
||||
}
|
||||
|
||||
private fun AnimatedContentTransitionScope<NavBackStackEntry>.isReturningFromSettingsChild(): Boolean {
|
||||
if (targetState.destination.route != AppRoute.Settings.pattern) return false
|
||||
return when (initialState.destination.route) {
|
||||
AppRoute.CacheManagement.pattern, AppRoute.About.pattern -> true
|
||||
else -> false
|
||||
}
|
||||
animationSpec = tween(
|
||||
// Seeking still maps gesture progress directly to position. The
|
||||
// longer timeline only softens completion/cancellation after release.
|
||||
if (gestureDirection != null) NavigationMotionTokens.predictivePopDuration
|
||||
else NavigationMotionTokens.detailPopDuration,
|
||||
easing = if (gestureDirection != null) LinearEasing else NavigationMotionTokens.popEasing,
|
||||
),
|
||||
) { width -> width * (gestureDirection ?: 1) }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.myapplication.navigation
|
||||
|
||||
import androidx.navigationevent.NavigationEvent
|
||||
|
||||
/** Keeps gesture geometry stable while NavHost switches from seeking to settling. */
|
||||
internal class PredictiveBackMotion {
|
||||
private var originEntryId: String? = null
|
||||
private var direction = 1
|
||||
|
||||
fun start(entryId: String, swipeEdge: Int): Int {
|
||||
originEntryId = entryId
|
||||
direction = if (swipeEdge == NavigationEvent.EDGE_RIGHT) -1 else 1
|
||||
return direction
|
||||
}
|
||||
|
||||
fun directionFor(entryId: String): Int? = direction.takeIf { originEntryId == entryId }
|
||||
|
||||
fun onEntryResumed(entryId: String) {
|
||||
if (originEntryId == entryId) originEntryId = null
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ fun FavoritesAllScreen(
|
||||
else -> type
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().background(LumaColors.background)) {
|
||||
// Server header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -260,8 +260,8 @@ fun HomeLibraryAllScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val anchor = sortAnchorBounds
|
||||
if (sortMenuMounted && anchor != null) {
|
||||
val anchor = if (sortMenuMounted) sortAnchorBounds else null
|
||||
if (anchor != null) {
|
||||
LiquidGlassPopupOverlay(
|
||||
anchorBounds = anchor,
|
||||
rootOrigin = rootOrigin,
|
||||
|
||||
@@ -3,19 +3,16 @@ package com.example.myapplication.screens
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -30,6 +27,7 @@ import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -44,9 +42,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -63,7 +63,6 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -75,6 +74,10 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
import kotlin.random.Random
|
||||
import coil.request.ImageRequest
|
||||
import com.example.myapplication.ui.design.rememberImageRequest
|
||||
import coil.imageLoader
|
||||
@@ -107,6 +110,8 @@ import com.example.myapplication.ui.design.lumaGlass
|
||||
import com.example.myapplication.ui.design.rememberLumaLayerBackdrop
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.PageSize
|
||||
import androidx.compose.foundation.pager.PagerDefaults
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -234,6 +239,24 @@ private fun homeAccentColor(): Color {
|
||||
}
|
||||
|
||||
/* ---- Hero Carousel ---- */
|
||||
internal fun heroInitialPage(itemCount: Int): Int =
|
||||
if (itemCount > 1) (Int.MAX_VALUE / 2 / itemCount) * itemCount else 0
|
||||
|
||||
internal fun heroItemIndex(page: Int, itemCount: Int): Int =
|
||||
if (itemCount > 0) Math.floorMod(page, itemCount) else 0
|
||||
|
||||
// Each artwork stays on its readable front face; the handoff occurs edge-on at 90 degrees.
|
||||
internal fun heroFlipRotation(pageOffset: Float): Float? =
|
||||
if (abs(pageOffset) <= 0.5f) -pageOffset * 180f else null
|
||||
|
||||
@Composable
|
||||
internal fun rememberHomeHeroItems(items: List<EmbyItem>, homeKey: String?, refreshToken: Int): List<EmbyItem> {
|
||||
// Navigation saves the pager position. Save its shuffle seed as well so that
|
||||
// returning restores the artwork and its cached palette at that position.
|
||||
val seed by rememberSaveable(homeKey, refreshToken) { mutableIntStateOf(Random.nextInt()) }
|
||||
return remember(items, seed) { items.shuffled(Random(seed)).take(8) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroCarousel(
|
||||
items: List<EmbyItem>,
|
||||
@@ -245,20 +268,20 @@ private fun HeroCarousel(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(pagerState, items.size, lifecycleOwner) {
|
||||
LaunchedEffect(pagerState, items.size, lifecycleOwner, pagerState.settledPage) {
|
||||
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
if (items.size < 2) return@repeatOnLifecycle
|
||||
while (true) {
|
||||
delay(5000)
|
||||
if (!pagerState.isScrollInProgress) {
|
||||
val next = (pagerState.currentPage + 1) % items.size
|
||||
pagerState.animateScrollToPage(next)
|
||||
val next = pagerState.currentPage + 1
|
||||
pagerState.animateScrollToPage(next, animationSpec = tween(500))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val nextItem = items.getOrNull((pagerState.currentPage + 1) % items.size.coerceAtLeast(1))
|
||||
val nextItem = items.getOrNull(heroItemIndex(pagerState.currentPage + 1, items.size))
|
||||
LaunchedEffect(nextItem?.id, serverUrl) {
|
||||
nextItem ?: return@LaunchedEffect
|
||||
heroImageUrl(nextItem, serverUrl)?.let { url ->
|
||||
@@ -269,18 +292,66 @@ private fun HeroCarousel(
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.padding(bottom = LumaSpacing.sm)) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxWidth().height(heroHeight),
|
||||
) { page ->
|
||||
FeaturedItemBanner(
|
||||
item = items[page],
|
||||
serverUrl = serverUrl,
|
||||
heroHeight = heroHeight,
|
||||
immersive = immersiveBackground,
|
||||
onClick = { onItemClick(items[page]) },
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(top = 12.dp, bottom = LumaSpacing.sm),
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(heroHeight + 16.dp)) {
|
||||
val cardWidth = maxWidth - 72.dp
|
||||
val rearSpacing = cardWidth * 0.08f + 12.dp
|
||||
val pageStride = cardWidth * 0.65f
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(pageStride),
|
||||
contentPadding = PaddingValues(horizontal = (maxWidth - pageStride) / 2),
|
||||
beyondViewportPageCount = 2,
|
||||
verticalAlignment = Alignment.Top,
|
||||
flingBehavior = PagerDefaults.flingBehavior(
|
||||
state = pagerState,
|
||||
snapAnimationSpec = tween(300),
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) { page ->
|
||||
val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
|
||||
if (abs(pageOffset) >= 3f) return@HorizontalPager
|
||||
val item = items[heroItemIndex(page, items.size)]
|
||||
val distance = abs(pageOffset).coerceIn(0f, 2f)
|
||||
val flipRotation = heroFlipRotation(pageOffset)
|
||||
val isFrontFace = flipRotation != null
|
||||
Box(
|
||||
Modifier
|
||||
.zIndex(if (isFrontFace) 4f else 2f - abs(pageOffset))
|
||||
.wrapContentSize(Alignment.TopCenter, unbounded = true)
|
||||
.width(cardWidth)
|
||||
.graphicsLayer {
|
||||
val cardScale = if (isFrontFace) 1f else 1f - 0.08f * distance
|
||||
scaleX = cardScale
|
||||
scaleY = cardScale
|
||||
val rearAngle = 18f * distance.coerceAtMost(1f) +
|
||||
8f * (distance - 1f).coerceAtLeast(0f)
|
||||
rotationY = flipRotation ?: (-sign(pageOffset) * rearAngle)
|
||||
cameraDistance = size.width * 8f
|
||||
// Keep the flipping face centred while the rear cards stay in their stack.
|
||||
val stridePx = pageStride.roundToPx().toFloat()
|
||||
translationX = pageOffset *
|
||||
if (isFrontFace) stridePx else stridePx - rearSpacing.toPx()
|
||||
alpha = if (isFrontFace) 1f else {
|
||||
((abs(pageOffset) - 0.5f) * 2f).coerceIn(0f, 1f) *
|
||||
(3f - abs(pageOffset)).coerceIn(0f, 1f)
|
||||
}
|
||||
},
|
||||
) {
|
||||
FeaturedItemBanner(
|
||||
item = item,
|
||||
serverUrl = serverUrl,
|
||||
heroHeight = heroHeight,
|
||||
immersive = immersiveBackground,
|
||||
contentAlpha = if (isFrontFace) 1f else 0f,
|
||||
onClick = { onItemClick(item) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(
|
||||
@@ -289,7 +360,7 @@ private fun HeroCarousel(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
for (i in items.indices) {
|
||||
val selected = i == pagerState.currentPage
|
||||
val selected = i == heroItemIndex(pagerState.currentPage, items.size)
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
@@ -316,42 +387,25 @@ private fun FeaturedItemBanner(
|
||||
modifier: Modifier = Modifier,
|
||||
heroHeight: androidx.compose.ui.unit.Dp,
|
||||
immersive: PosterImmersivePalette,
|
||||
contentAlpha: Float = 1f,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
val imageUrl = heroImageUrl(item, serverUrl)
|
||||
// P2: Remember ImageRequest to avoid allocation on every recomposition
|
||||
val context = LocalContext.current
|
||||
val imageRequest = remember(imageUrl) {
|
||||
imageUrl?.let { ImageRequest.Builder(context).data(it).crossfade(true).build() }
|
||||
imageUrl?.let { ImageRequest.Builder(context).data(it).crossfade(false).build() }
|
||||
}
|
||||
|
||||
// Press feedback: pointer-based scale animation consistent with LumaMediaCard
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isPressed) 0.97f else 1f,
|
||||
animationSpec = tween(LumaMotion.fast),
|
||||
label = "heroPress",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
role = Role.Button,
|
||||
onClick = onClick,
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
isPressed = true
|
||||
val up = waitForUpOrCancellation()
|
||||
isPressed = up != null
|
||||
}
|
||||
}
|
||||
.background(immersive.pageTop),
|
||||
) {
|
||||
if (imageRequest != null) {
|
||||
@@ -364,31 +418,29 @@ private fun FeaturedItemBanner(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Box(Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.40f),
|
||||
0.2f to Color.Transparent,
|
||||
)
|
||||
))
|
||||
|
||||
Box(Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
0.30f to Color.Transparent,
|
||||
0.45f to immersive.hero.copy(alpha = 0.06f),
|
||||
0.55f to immersive.hero.copy(alpha = 0.14f),
|
||||
0.63f to immersive.hero.copy(alpha = 0.24f),
|
||||
0.70f to immersive.hero.copy(alpha = 0.36f),
|
||||
0.77f to immersive.hero.copy(alpha = 0.50f),
|
||||
0.83f to immersive.hero.copy(alpha = 0.66f),
|
||||
0.89f to immersive.hero.copy(alpha = 0.80f),
|
||||
0.94f to immersive.hero.copy(alpha = 0.92f),
|
||||
1f to immersive.hero,
|
||||
)
|
||||
))
|
||||
// Rear cards display only artwork. Alpha alone would still compose and
|
||||
// measure their hidden logos, titles and overviews when restoring home.
|
||||
if (contentAlpha <= 0f) return@Box
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.height(190.dp)
|
||||
.alpha(contentAlpha)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
0.40f to Color.Black.copy(alpha = 0.20f),
|
||||
1f to Color.Black.copy(alpha = 0.62f),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Column(
|
||||
Modifier.align(Alignment.BottomCenter).padding(start = 24.dp, end = 24.dp, bottom = 22.dp),
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.alpha(contentAlpha)
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 22.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
@@ -490,9 +542,17 @@ private fun MediaPoster(
|
||||
ImageRequest.Builder(context).data(it).crossfade(true).build()
|
||||
}
|
||||
}
|
||||
val subtitle = listOfNotNull(
|
||||
item.communityRating?.takeIf { it > 0.0 }?.let { "★ ${String.format("%.1f", it)}" },
|
||||
item.productionYear?.let { "$it" },
|
||||
).takeIf { it.isNotEmpty() }?.joinToString(" ")
|
||||
|
||||
LumaMediaCard(
|
||||
model = imageRequest,
|
||||
title = item.seriesName ?: item.name,
|
||||
subtitle = subtitle,
|
||||
subtitleColor = homeSecondaryColor(),
|
||||
subtitleStarColor = LumaColors.ratingStar,
|
||||
modifier = modifier.width(115.dp),
|
||||
imageAspectRatio = 115f / 165f,
|
||||
titleColor = homeContentColor(),
|
||||
@@ -590,6 +650,7 @@ private fun LazyLibrarySection(
|
||||
refreshToken: Int,
|
||||
onItemClick: (EmbyItem) -> Unit = {},
|
||||
onViewAll: () -> Unit = {},
|
||||
onEmpty: () -> Unit = {},
|
||||
) {
|
||||
val cacheKey = remember(activeServer.id, activeServer.fullUrl, activeServer.userId) {
|
||||
homeCacheKey(activeServer)
|
||||
@@ -616,6 +677,7 @@ private fun LazyLibrarySection(
|
||||
EmbyLibraryPage()
|
||||
}.also { loaded ->
|
||||
HomeContentCache.putLibraryPage(cacheKey, folder.id, loaded)
|
||||
if (loaded.items.isEmpty()) onEmpty()
|
||||
}
|
||||
}
|
||||
// Fetch accurate episode counts for series items (parallel)
|
||||
@@ -637,7 +699,7 @@ private fun LazyLibrarySection(
|
||||
}
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SectionTitle(title = folder.name, count = page?.totalCount ?: 0, onMoreClick = onViewAll)
|
||||
val list = page?.items
|
||||
if (list == null) {
|
||||
@@ -676,13 +738,12 @@ private fun SectionTitle(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(text = title, color = homeContentColor(), fontSize = 18.sp, fontWeight = FontWeight.ExtraBold)
|
||||
Text(text = title, color = homeContentColor(), fontSize = 15.sp, fontWeight = FontWeight.Bold)
|
||||
if (count > 0) {
|
||||
Box(
|
||||
Modifier
|
||||
.heightIn(min = 48.dp)
|
||||
.clickable(onClick = { onMoreClick?.invoke() })
|
||||
.padding(horizontal = 8.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
@@ -698,10 +759,10 @@ private fun SectionTitle(
|
||||
|
||||
@Composable
|
||||
private fun MyMediaGrid(libraries: List<EmbyMediaFolder>, serverUrl: String, onSelect: (EmbyMediaFolder) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SectionTitle(title = stringResource(Res.string.section_library))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items(libraries) { folder ->
|
||||
items(libraries, key = { it.id }) { folder ->
|
||||
Column(
|
||||
Modifier.width(176.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -866,7 +927,7 @@ private fun RainbowNumber(
|
||||
LumaColors.rainbowGradient
|
||||
),
|
||||
fontSize = fontSize,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
maxLines = 1,
|
||||
@@ -898,19 +959,27 @@ fun HomeScreen(
|
||||
var reloadToken by remember(homeKey) { mutableIntStateOf(0) }
|
||||
var showResourceStats by remember(homeKey) { mutableStateOf(false) }
|
||||
var serverMenuVisible by remember(homeKey) { mutableStateOf(false) }
|
||||
val emptyLibraries = remember(homeKey) { mutableStateMapOf<String, Boolean>() }
|
||||
var serverMenuMounted by remember(homeKey) { mutableStateOf(false) }
|
||||
var serverAnchorBounds by remember { mutableStateOf<Rect?>(null) }
|
||||
var homeRootOrigin by remember { mutableStateOf(Offset.Zero) }
|
||||
val homeBackdrop = rememberLumaLayerBackdrop()
|
||||
val heroHeight = (LocalConfiguration.current.screenHeightDp.dp * 0.5f)
|
||||
val heroHeight = (LocalConfiguration.current.screenHeightDp.dp * 0.54f).coerceIn(300.dp, 480.dp)
|
||||
|
||||
val heroItems = remember(latestItems, reloadToken) {
|
||||
latestItems.shuffled().take(8) // 每次进入/刷新都随机排序
|
||||
val heroItems = rememberHomeHeroItems(latestItems, homeKey, reloadToken)
|
||||
val heroPagerState = key(heroItems) {
|
||||
rememberPagerState(
|
||||
initialPage = heroInitialPage(heroItems.size),
|
||||
pageCount = { if (heroItems.size > 1) Int.MAX_VALUE else heroItems.size },
|
||||
)
|
||||
}
|
||||
val heroPagerState = rememberPagerState(pageCount = { heroItems.size })
|
||||
val homeListState = rememberLazyListState()
|
||||
val activeHeroItem = heroItems.getOrNull(heroPagerState.currentPage)
|
||||
val immersiveBackground = rememberPosterImmersiveBackground(heroImageUrl(activeHeroItem, activeServer?.fullUrl.orEmpty()))
|
||||
val activeHeroItem = heroItems.getOrNull(heroItemIndex(heroPagerState.currentPage, heroItems.size))
|
||||
val immersiveBackground = rememberPosterImmersiveBackground(
|
||||
heroImageUrl(activeHeroItem, activeServer?.fullUrl.orEmpty()),
|
||||
vivid = true,
|
||||
imageAspectRatio = (LocalConfiguration.current.screenWidthDp - 72f) / heroHeight.value,
|
||||
)
|
||||
val hasHomeContent = heroItems.isNotEmpty() || resumeItems.isNotEmpty() || libraries.isNotEmpty()
|
||||
val useImmersiveHomeBackground = activeServer != null && hasHomeContent
|
||||
val requestReload: () -> Unit = {
|
||||
@@ -969,14 +1038,14 @@ fun HomeScreen(
|
||||
}
|
||||
val adaptiveSecondary = if (useImmersiveHomeBackground) adaptiveText.copy(alpha = 0.7f) else LumaColors.textSecondary
|
||||
val adaptiveAccent = if (useImmersiveHomeBackground) {
|
||||
if (immersiveBackground.isDark) Color(0xFF6EA8FF) else Color(0xFF0061A4)
|
||||
if (immersiveBackground.isDark) Color(0xFFB5D9FF) else Color(0xFF0B293E)
|
||||
} else {
|
||||
LumaColors.accent
|
||||
}
|
||||
val homeBackground = if (useImmersiveHomeBackground) {
|
||||
Brush.verticalGradient(
|
||||
0f to immersiveBackground.hero,
|
||||
0.50f to immersiveBackground.hero,
|
||||
0.60f to immersiveBackground.pageTop,
|
||||
1f to immersiveBackground.pageBottom,
|
||||
)
|
||||
} else {
|
||||
@@ -1064,8 +1133,8 @@ fun HomeScreen(
|
||||
|
||||
if (resumeItems.isNotEmpty()) {
|
||||
item(key = "resume", contentType = "resume") {
|
||||
HomeSection(first = true) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HomeSection {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SectionTitle(title = stringResource(Res.string.continue_watching))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(resumeItems, key = { it.id }) { item ->
|
||||
@@ -1081,7 +1150,7 @@ fun HomeScreen(
|
||||
|
||||
if (libraries.isNotEmpty()) {
|
||||
item(key = "media", contentType = "media") {
|
||||
HomeSection(first = resumeItems.isEmpty()) {
|
||||
HomeSection {
|
||||
MyMediaGrid(libraries, server.fullUrl) { folder ->
|
||||
onViewAll(server.id, folder.id, folder.name)
|
||||
}
|
||||
@@ -1089,7 +1158,8 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
items(libraries, key = { "library_${it.id}" }, contentType = { "library" }) { folder ->
|
||||
val visibleLibraries = libraries.filter { emptyLibraries[it.id] != true }
|
||||
items(visibleLibraries, key = { "library_${it.id}" }, contentType = { "library" }) { folder ->
|
||||
HomeSection {
|
||||
LazyLibrarySection(
|
||||
folder = folder,
|
||||
@@ -1097,12 +1167,13 @@ fun HomeScreen(
|
||||
refreshToken = reloadToken,
|
||||
onItemClick = { onOpenDetail(it.id) },
|
||||
onViewAll = { onViewAll(server.id, folder.id, folder.name) },
|
||||
onEmpty = { emptyLibraries[folder.id] = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "stats", contentType = "stats") {
|
||||
HomeSection(first = resumeItems.isEmpty() && libraries.isEmpty()) {
|
||||
HomeSection {
|
||||
ResourceStatsCard(
|
||||
stats = resourceStats,
|
||||
onClick = { showResourceStats = true },
|
||||
@@ -1131,9 +1202,11 @@ fun HomeScreen(
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
)
|
||||
|
||||
val anchor = serverAnchorBounds
|
||||
// Moving the page changes global coordinates on every animation frame.
|
||||
// Observe them only while the popup actually needs positioning.
|
||||
val anchor = if (serverMenuMounted) serverAnchorBounds else null
|
||||
val selectableServers = servers.filter { it.apiKey.isNotBlank() }
|
||||
if (serverMenuMounted && anchor != null) {
|
||||
if (anchor != null) {
|
||||
LiquidGlassPopupOverlay(
|
||||
anchorBounds = anchor,
|
||||
rootOrigin = homeRootOrigin,
|
||||
@@ -1172,12 +1245,12 @@ fun HomeScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeSection(first: Boolean = false, content: @Composable () -> Unit) {
|
||||
private fun HomeSection(content: @Composable () -> Unit) {
|
||||
Box(
|
||||
Modifier.fillMaxWidth().padding(
|
||||
start = LumaSpacing.md,
|
||||
end = LumaSpacing.md,
|
||||
top = if (first) LumaSpacing.md else LumaSpacing.xl,
|
||||
top = LumaSpacing.md,
|
||||
),
|
||||
) {
|
||||
content()
|
||||
|
||||
@@ -629,7 +629,7 @@ fun MediaDetailScreen(
|
||||
.weight(1f)
|
||||
.heightIn(min = 48.dp)
|
||||
.clickable(enabled = episodes.isNotEmpty()) { showAllEpisodes = true },
|
||||
contentAlignment = Alignment.Center,
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.detail_view_all),
|
||||
@@ -722,22 +722,15 @@ fun MediaDetailScreen(
|
||||
}
|
||||
}
|
||||
// Pinned above the scrolling content so it stays visible while browsing episodes.
|
||||
Box(
|
||||
Modifier
|
||||
LiquidGlassButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 10.dp)
|
||||
.size(48.dp)
|
||||
.lumaGlass(
|
||||
backdrop = LocalGlassBackdrop.current,
|
||||
radius = 24.dp,
|
||||
level = LumaGlassLevel.Floating,
|
||||
tint = Color.Black.copy(alpha = 0.30f),
|
||||
borderColor = Color.White.copy(alpha = 0.16f),
|
||||
shadowElevation = 4.dp,
|
||||
)
|
||||
.clickable(onClick = onClose),
|
||||
contentAlignment = Alignment.Center,
|
||||
.size(48.dp),
|
||||
cornerRadius = 24.dp,
|
||||
fill = GlassTokens.glassFillStrong,
|
||||
) {
|
||||
LumaIconView(
|
||||
icon = LumaIcon.Back,
|
||||
@@ -1760,15 +1753,6 @@ private fun EpisodeListDialog(
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.detail_close),
|
||||
color = LocalDetailSecondary.current,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.heightIn(min = 48.dp)
|
||||
.clickable(onClick = onDismiss),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
LazyColumn(
|
||||
|
||||
@@ -90,7 +90,7 @@ fun RecordsTopBar(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
LiquidBottomTabs(
|
||||
@@ -98,8 +98,8 @@ fun RecordsTopBar(
|
||||
onTabSelected = onSelect,
|
||||
backdrop = backdrop,
|
||||
tabsCount = labels.size,
|
||||
modifier = Modifier.width(220.dp),
|
||||
height = 56.dp,
|
||||
modifier = Modifier.width(200.dp),
|
||||
height = 48.dp,
|
||||
surfaceAlpha = 0.16f,
|
||||
pressHighlight = false,
|
||||
ghostContent = {},
|
||||
@@ -182,7 +182,7 @@ fun RecordsScreen(
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
top = 8.dp,
|
||||
top = 2.dp,
|
||||
bottom = bottomBarContentPadding().calculateBottomPadding(),
|
||||
),
|
||||
) {
|
||||
@@ -244,7 +244,7 @@ private fun ServerHeader(server: EmbyServer) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
@@ -295,7 +295,7 @@ private fun FavoriteSectionHeader(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.heightIn(min = 40.dp)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
|
||||
@@ -91,7 +91,7 @@ fun SearchAllScreen(
|
||||
loading = false
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().background(LumaColors.background)) {
|
||||
// Server header: icon + name + back
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -38,6 +40,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.example.myapplication.R
|
||||
import com.example.myapplication.BuildConfig
|
||||
import com.example.myapplication.data.IconLibraryRepository
|
||||
import com.example.myapplication.data.PlayerSettings
|
||||
import com.example.myapplication.ui.GlassConfirmDialog
|
||||
import com.example.myapplication.ui.GlassTokens
|
||||
import com.example.myapplication.ui.LocalGlassBackdrop
|
||||
@@ -45,6 +48,7 @@ import com.example.myapplication.ui.design.LumaColors
|
||||
import com.example.myapplication.ui.design.LumaIcon
|
||||
import com.example.myapplication.ui.design.LumaIconView
|
||||
import com.example.myapplication.ui.design.LumaPageTopBar
|
||||
import com.example.myapplication.ui.design.LumaSettingRow
|
||||
import com.example.myapplication.ui.design.LumaShapes
|
||||
import com.example.myapplication.ui.design.LumaSpacing
|
||||
import com.example.myapplication.ui.design.LumaTypography
|
||||
@@ -293,6 +297,275 @@ fun AboutScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlaybackSettingsScreen(onBack: () -> Unit) {
|
||||
var showDefaultSpeedMenu by remember { mutableStateOf(false) }
|
||||
var showLongPressSpeedMenu by remember { mutableStateOf(false) }
|
||||
var showSeekSecondsMenu by remember { mutableStateOf(false) }
|
||||
var showReportIntervalMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val defaultSpeedOptions = listOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f)
|
||||
val longPressSpeedOptions = listOf(1.5f, 2.0f, 2.5f, 3.0f, 4.0f)
|
||||
val seekSecondsOptions = listOf(5, 10, 15, 30)
|
||||
val reportIntervalOptions = listOf(5_000L, 10_000L, 30_000L, 60_000L)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(LumaColors.background),
|
||||
)
|
||||
CompositionLocalProvider(LocalGlassBackdrop provides null) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
LumaPageTopBar(
|
||||
title = stringResource(R.string.settings_playback_settings),
|
||||
onBack = onBack,
|
||||
backContentDescription = stringResource(R.string.back),
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Speed section
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.settings_playback_section),
|
||||
color = GlassTokens.subtext,
|
||||
fontSize = LumaTypography.secondary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = LumaSpacing.sm),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.settingsModuleSurface(radius = LumaShapes.large)
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
LumaSettingRow(
|
||||
icon = LumaIcon.Play,
|
||||
title = stringResource(R.string.settings_default_speed),
|
||||
subtitle = "${PlayerSettings.defaultSpeed}x",
|
||||
onClick = { showDefaultSpeedMenu = true },
|
||||
)
|
||||
DividerLine()
|
||||
LumaSettingRow(
|
||||
icon = LumaIcon.Speed,
|
||||
title = stringResource(R.string.settings_long_press_speed),
|
||||
subtitle = "${PlayerSettings.longPressSpeed}x",
|
||||
onClick = { showLongPressSpeedMenu = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Seek section
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.settings_seek_seconds),
|
||||
color = GlassTokens.subtext,
|
||||
fontSize = LumaTypography.secondary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = LumaSpacing.sm),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.settingsModuleSurface(radius = LumaShapes.large)
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
LumaSettingRow(
|
||||
icon = LumaIcon.Next,
|
||||
title = stringResource(R.string.settings_seek_seconds),
|
||||
subtitle = "${PlayerSettings.seekSeconds}s",
|
||||
onClick = { showSeekSecondsMenu = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Skip section
|
||||
item {
|
||||
Text(
|
||||
"跳过",
|
||||
color = GlassTokens.subtext,
|
||||
fontSize = LumaTypography.secondary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = LumaSpacing.sm),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.settingsModuleSurface(radius = LumaShapes.large)
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
SwitchSettingRow(
|
||||
icon = LumaIcon.Next,
|
||||
title = "自动跳过片头/片尾",
|
||||
subtitle = "播放到标记位置时自动跳过",
|
||||
checked = PlayerSettings.autoSkipIntro && PlayerSettings.autoSkipOutro,
|
||||
onCheckedChange = { enabled ->
|
||||
PlayerSettings.updateAutoSkipIntro(enabled)
|
||||
PlayerSettings.updateAutoSkipOutro(enabled)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
// Advanced section
|
||||
item {
|
||||
Text(
|
||||
"高级",
|
||||
color = GlassTokens.subtext,
|
||||
fontSize = LumaTypography.secondary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = LumaSpacing.sm),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.settingsModuleSurface(radius = LumaShapes.large)
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
LumaSettingRow(
|
||||
icon = LumaIcon.Settings,
|
||||
title = stringResource(R.string.settings_progress_report_interval),
|
||||
subtitle = "${PlayerSettings.progressReportIntervalMs / 1000}s",
|
||||
onClick = { showReportIntervalMenu = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Speed selection dialogs
|
||||
if (showDefaultSpeedMenu) {
|
||||
SingleChoiceDialog(
|
||||
title = stringResource(R.string.settings_default_speed),
|
||||
options = defaultSpeedOptions.map { "${it}x" },
|
||||
selectedIndex = defaultSpeedOptions.indexOf(PlayerSettings.defaultSpeed).coerceAtLeast(0),
|
||||
onSelect = { idx -> PlayerSettings.updateDefaultSpeed(defaultSpeedOptions[idx]) },
|
||||
onDismiss = { showDefaultSpeedMenu = false },
|
||||
)
|
||||
}
|
||||
if (showLongPressSpeedMenu) {
|
||||
SingleChoiceDialog(
|
||||
title = stringResource(R.string.settings_long_press_speed),
|
||||
options = longPressSpeedOptions.map { "${it}x" },
|
||||
selectedIndex = longPressSpeedOptions.indexOf(PlayerSettings.longPressSpeed).coerceAtLeast(0),
|
||||
onSelect = { idx -> PlayerSettings.updateLongPressSpeed(longPressSpeedOptions[idx]) },
|
||||
onDismiss = { showLongPressSpeedMenu = false },
|
||||
)
|
||||
}
|
||||
if (showSeekSecondsMenu) {
|
||||
SingleChoiceDialog(
|
||||
title = stringResource(R.string.settings_seek_seconds),
|
||||
options = seekSecondsOptions.map { "${it}s" },
|
||||
selectedIndex = seekSecondsOptions.indexOf(PlayerSettings.seekSeconds).coerceAtLeast(0),
|
||||
onSelect = { idx -> PlayerSettings.updateSeekSeconds(seekSecondsOptions[idx]) },
|
||||
onDismiss = { showSeekSecondsMenu = false },
|
||||
)
|
||||
}
|
||||
if (showReportIntervalMenu) {
|
||||
SingleChoiceDialog(
|
||||
title = stringResource(R.string.settings_progress_report_interval),
|
||||
options = reportIntervalOptions.map { "${it / 1000}s" },
|
||||
selectedIndex = reportIntervalOptions.indexOf(PlayerSettings.progressReportIntervalMs).coerceAtLeast(0),
|
||||
onSelect = { idx -> PlayerSettings.updateProgressReportIntervalMs(reportIntervalOptions[idx]) },
|
||||
onDismiss = { showReportIntervalMenu = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchSettingRow(
|
||||
icon: LumaIcon,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onCheckedChange(!checked) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LumaIconView(icon, contentDescription = null, tint = LumaColors.accent, modifier = Modifier.size(21.dp))
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(subtitle, color = GlassTokens.subtext, fontSize = LumaTypography.caption)
|
||||
}
|
||||
top.yukonga.miuix.kmp.basic.Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SingleChoiceDialog(
|
||||
title: String,
|
||||
options: List<String>,
|
||||
selectedIndex: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(280.dp)
|
||||
.settingsModuleSurface(radius = LumaShapes.large)
|
||||
.padding(vertical = LumaSpacing.sm),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
title,
|
||||
color = GlassTokens.text,
|
||||
fontSize = LumaTypography.title,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = LumaSpacing.md, vertical = LumaSpacing.sm),
|
||||
)
|
||||
options.forEachIndexed { index, label ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 44.dp)
|
||||
.padding(horizontal = 4.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(if (index == selectedIndex) LumaColors.accentSoft else Color.Transparent)
|
||||
.clickable(indication = null, interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }) {
|
||||
onSelect(index)
|
||||
onDismiss()
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (index == selectedIndex) LumaColors.accent else LumaColors.text,
|
||||
fontSize = LumaTypography.body,
|
||||
fontWeight = if (index == selectedIndex) FontWeight.SemiBold else FontWeight.Normal,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (index == selectedIndex) {
|
||||
LumaIconView(LumaIcon.Check, contentDescription = null, tint = LumaColors.accent, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutSectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
@@ -332,6 +605,11 @@ private fun AboutInfoRow(icon: LumaIcon, title: String, subtitle: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DividerLine() {
|
||||
Box(Modifier.fillMaxWidth().height(0.5.dp).padding(horizontal = LumaSpacing.md).background(LumaColors.divider))
|
||||
}
|
||||
|
||||
internal fun Modifier.settingsModuleSurface(radius: Dp): Modifier {
|
||||
val shape = RoundedCornerShape(radius)
|
||||
val fill = if (LumaColors.isDark) LumaColors.backgroundElevated else Color.White
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.myapplication.R
|
||||
import com.example.myapplication.data.EmbyServer
|
||||
import com.example.myapplication.data.PlayerSettings
|
||||
import com.example.myapplication.ui.AppLanguage
|
||||
import com.example.myapplication.ui.AppTheme
|
||||
import com.example.myapplication.ui.GlassMode
|
||||
@@ -78,6 +79,7 @@ fun SettingsScreen(
|
||||
servers: List<EmbyServer> = emptyList(),
|
||||
onOpenCacheDetail: () -> Unit = {},
|
||||
onOpenAbout: () -> Unit = {},
|
||||
onOpenPlaybackSettings: () -> Unit = {},
|
||||
) {
|
||||
var showLanguageDialog by remember { mutableStateOf(false) }
|
||||
var languageMenuMounted by remember { mutableStateOf(false) }
|
||||
@@ -154,8 +156,8 @@ fun SettingsScreen(
|
||||
LumaSettingRow(
|
||||
icon = LumaIcon.Play,
|
||||
title = stringResource(R.string.settings_playback_row),
|
||||
subtitle = stringResource(R.string.settings_playback_row_sub),
|
||||
onClick = {},
|
||||
subtitle = playbackSettingsLabel(),
|
||||
onClick = { onOpenPlaybackSettings() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,8 +179,8 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val anchor = languageEntryBounds
|
||||
if (languageMenuMounted && anchor != null) {
|
||||
val anchor = if (languageMenuMounted) languageEntryBounds else null
|
||||
if (anchor != null) {
|
||||
LanguageMenuOverlay(
|
||||
anchorBounds = anchor,
|
||||
rootOrigin = settingsRootOrigin,
|
||||
@@ -195,8 +197,8 @@ fun SettingsScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
val themeAnchor = themeEntryBounds
|
||||
if (themeMenuMounted && themeAnchor != null) {
|
||||
val themeAnchor = if (themeMenuMounted) themeEntryBounds else null
|
||||
if (themeAnchor != null) {
|
||||
ThemeMenuOverlay(
|
||||
anchorBounds = themeAnchor,
|
||||
rootOrigin = settingsRootOrigin,
|
||||
@@ -210,8 +212,8 @@ fun SettingsScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
val glassModeAnchor = glassModeEntryBounds
|
||||
if (glassModeMenuMounted && glassModeAnchor != null) {
|
||||
val glassModeAnchor = if (glassModeMenuMounted) glassModeEntryBounds else null
|
||||
if (glassModeAnchor != null) {
|
||||
GlassModeMenuOverlay(
|
||||
anchorBounds = glassModeAnchor,
|
||||
rootOrigin = settingsRootOrigin,
|
||||
@@ -406,3 +408,11 @@ private fun LanguageMenuOverlay(
|
||||
private fun settingsPopupContainerColor(): Color = LumaColors.settingsPopupBg.copy(alpha = 0.72f)
|
||||
|
||||
private fun settingsPopupBorderColor(): Color = LumaColors.settingsPopupBorder
|
||||
|
||||
@Composable
|
||||
private fun playbackSettingsLabel(): String {
|
||||
val parts = mutableListOf<String>()
|
||||
parts.add("${PlayerSettings.defaultSpeed}x")
|
||||
if (PlayerSettings.autoSkipIntro && PlayerSettings.autoSkipOutro) parts.add("自动跳过")
|
||||
return parts.joinToString(" · ")
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,10 @@
|
||||
package com.example.myapplication.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -11,6 +13,8 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -30,66 +34,89 @@ fun LiquidAppBottomBar(
|
||||
onSelect: (Int) -> Unit,
|
||||
backdrop: Backdrop?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentTint: Color = Color.Transparent,
|
||||
) {
|
||||
val active = LumaColors.accent
|
||||
val inactive = LumaColors.textSecondary
|
||||
// iOS 26: subtle content-aware color overlay for glass tinting
|
||||
val tintOverlay = if (contentTint != Color.Transparent) {
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color.Transparent,
|
||||
contentTint.copy(alpha = 0.05f),
|
||||
)
|
||||
)
|
||||
} else null
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(start = LumaSpacing.lg, end = LumaSpacing.lg, bottom = LumaSpacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
LiquidBottomTabs(
|
||||
selectedTabIndex = { selectedTab.coerceIn(0, 3) },
|
||||
onTabSelected = onSelect,
|
||||
backdrop = backdrop,
|
||||
tabsCount = 4,
|
||||
showSelection = { selectedTab < 4 },
|
||||
) {
|
||||
for (index in mainTabs.indices) {
|
||||
val tab = mainTabs[index]
|
||||
LiquidBottomTab(onClick = { onSelect(index) }) {
|
||||
LumaAnimatedIcon(
|
||||
icon = tab.icon,
|
||||
selected = selectedTab == index,
|
||||
contentDescription = stringResource(tab.labelRes),
|
||||
tint = if (selectedTab == index) active else inactive,
|
||||
modifier = Modifier.size(26.dp),
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(tab.labelRes),
|
||||
color = if (selectedTab == index) active else inactive,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = if (selectedTab == index) FontWeight.SemiBold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
Box(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(start = LumaSpacing.lg, end = LumaSpacing.lg, bottom = LumaSpacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
LiquidBottomTabs(
|
||||
selectedTabIndex = { selectedTab.coerceIn(0, 3) },
|
||||
onTabSelected = onSelect,
|
||||
backdrop = backdrop,
|
||||
tabsCount = 4,
|
||||
showSelection = { selectedTab < 4 },
|
||||
) {
|
||||
for (index in mainTabs.indices) {
|
||||
val tab = mainTabs[index]
|
||||
LiquidBottomTab(onClick = { onSelect(index) }) {
|
||||
LumaAnimatedIcon(
|
||||
icon = tab.icon,
|
||||
selected = selectedTab == index,
|
||||
contentDescription = stringResource(tab.labelRes),
|
||||
tint = if (selectedTab == index) active else inactive,
|
||||
modifier = Modifier.size(26.dp),
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(tab.labelRes),
|
||||
color = if (selectedTab == index) active else inactive,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = if (selectedTab == index) FontWeight.SemiBold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(LumaSpacing.sm))
|
||||
|
||||
LiquidBottomTabs(
|
||||
selectedTabIndex = { 0 },
|
||||
onTabSelected = { onSelect(4) },
|
||||
backdrop = backdrop,
|
||||
tabsCount = 1,
|
||||
showSelection = { selectedTab == 4 },
|
||||
modifier = Modifier.size(64.dp),
|
||||
) {
|
||||
LiquidBottomTab(onClick = { onSelect(4) }) {
|
||||
LumaAnimatedIcon(
|
||||
icon = LumaIcon.Search,
|
||||
selected = selectedTab == 4,
|
||||
contentDescription = stringResource(com.example.myapplication.R.string.search),
|
||||
tint = if (selectedTab == 4) active else inactive,
|
||||
modifier = Modifier.size(27.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(LumaSpacing.sm))
|
||||
|
||||
LiquidBottomTabs(
|
||||
selectedTabIndex = { 0 },
|
||||
onTabSelected = { onSelect(4) },
|
||||
backdrop = backdrop,
|
||||
tabsCount = 1,
|
||||
showSelection = { selectedTab == 4 },
|
||||
modifier = Modifier.size(64.dp),
|
||||
) {
|
||||
LiquidBottomTab(onClick = { onSelect(4) }) {
|
||||
LumaAnimatedIcon(
|
||||
icon = LumaIcon.Search,
|
||||
selected = selectedTab == 4,
|
||||
contentDescription = stringResource(com.example.myapplication.R.string.search),
|
||||
tint = if (selectedTab == 4) active else inactive,
|
||||
modifier = Modifier.size(27.dp),
|
||||
)
|
||||
}
|
||||
// iOS 26: subtle content-aware tint overlay (visible only when contentTint is provided)
|
||||
tintOverlay?.let { brush ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.height(80.dp)
|
||||
.background(brush),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.example.myapplication.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.util.LruCache
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
@@ -15,12 +16,14 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.palette.graphics.Palette
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Immutable
|
||||
data class PosterImmersivePalette(
|
||||
@@ -32,7 +35,14 @@ data class PosterImmersivePalette(
|
||||
|
||||
// Keep only derived colors, never bitmaps. Returning to a page can draw its
|
||||
// final colors immediately without decoding or animating from the fallback.
|
||||
private val posterPaletteCache = LruCache<Pair<String, Boolean>, PosterImmersivePalette>(32)
|
||||
private data class PosterPaletteKey(
|
||||
val imageUrl: String,
|
||||
val themeDark: Boolean,
|
||||
val vivid: Boolean,
|
||||
val imageAspectRatio: Float,
|
||||
)
|
||||
|
||||
private val posterPaletteCache = LruCache<PosterPaletteKey, PosterImmersivePalette>(32)
|
||||
|
||||
/**
|
||||
* Extracts an ambient palette from a poster/backdrop while preserving the real
|
||||
@@ -43,14 +53,16 @@ private val posterPaletteCache = LruCache<Pair<String, Boolean>, PosterImmersive
|
||||
fun rememberPosterImmersiveBackground(
|
||||
imageUrl: String?,
|
||||
themeDark: Boolean = GlassTokens.isDark,
|
||||
vivid: Boolean = false,
|
||||
imageAspectRatio: Float = 1f,
|
||||
): PosterImmersivePalette {
|
||||
val cacheKey = imageUrl?.takeIf { it.isNotBlank() }?.let { it to themeDark }
|
||||
var extracted by remember(imageUrl, themeDark) {
|
||||
val cacheKey = imageUrl?.takeIf { it.isNotBlank() }?.let { PosterPaletteKey(it, themeDark, vivid, imageAspectRatio) }
|
||||
var extracted by remember(cacheKey, themeDark) {
|
||||
mutableStateOf(cacheKey?.let(posterPaletteCache::get) ?: defaultPosterPalette(themeDark))
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(imageUrl, themeDark) {
|
||||
LaunchedEffect(cacheKey, themeDark) {
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
extracted = defaultPosterPalette(themeDark)
|
||||
return@LaunchedEffect
|
||||
@@ -64,15 +76,13 @@ fun rememberPosterImmersiveBackground(
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(imageUrl)
|
||||
.allowHardware(false)
|
||||
.size(48)
|
||||
.size(96)
|
||||
.build()
|
||||
val bitmap = (context.imageLoader.execute(request).drawable as? BitmapDrawable)?.bitmap
|
||||
|
||||
if (bitmap != null && !bitmap.isRecycled) {
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
val palette = Palette.from(bitmap).maximumColorCount(32).generate()
|
||||
val sourceArgb = selectRepresentativeColor(palette)
|
||||
deriveAmbientPalette(Color(sourceArgb), themeDark)
|
||||
extractPosterPalette(bitmap, themeDark, vivid, imageAspectRatio)
|
||||
}
|
||||
if (cacheKey != null) posterPaletteCache.put(cacheKey, result)
|
||||
extracted = result
|
||||
@@ -108,6 +118,26 @@ fun rememberPosterImmersiveBackground(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun extractPosterPalette(
|
||||
bitmap: Bitmap,
|
||||
themeDark: Boolean,
|
||||
vivid: Boolean = false,
|
||||
imageAspectRatio: Float = 1f,
|
||||
): PosterImmersivePalette {
|
||||
// Sample the same centered crop that ContentScale.Crop actually displays.
|
||||
val width = minOf(bitmap.width, (bitmap.height * imageAspectRatio).roundToInt().coerceAtLeast(1))
|
||||
val height = minOf(bitmap.height, (bitmap.width / imageAspectRatio).roundToInt().coerceAtLeast(1))
|
||||
val left = (bitmap.width - width) / 2
|
||||
val top = (bitmap.height - height) / 2
|
||||
val palette = Palette.from(bitmap)
|
||||
.setRegion(left, top, left + width, top + height)
|
||||
// Palette's default filter rejects warm reds and neutral artwork.
|
||||
.clearFilters()
|
||||
.maximumColorCount(32)
|
||||
.generate()
|
||||
return deriveAmbientPalette(Color(selectRepresentativeColor(palette)), themeDark, vivid)
|
||||
}
|
||||
|
||||
private fun defaultPosterPalette(themeDark: Boolean): PosterImmersivePalette = if (themeDark) {
|
||||
PosterImmersivePalette(
|
||||
hero = Color(0xFF10161E),
|
||||
@@ -134,9 +164,14 @@ private fun selectRepresentativeColor(palette: Palette): Int {
|
||||
if (swatches.isEmpty()) return fallback
|
||||
|
||||
val maxPopulation = swatches.maxOf { it.population }.toFloat().coerceAtLeast(1f)
|
||||
val totalPopulation = palette.swatches.sumOf { it.population }
|
||||
val hsv = FloatArray(3)
|
||||
val colorfulSwatches = swatches.filter { swatch ->
|
||||
android.graphics.Color.colorToHSV(swatch.rgb, hsv)
|
||||
hsv[1] >= 0.18f && hsv[2] >= 0.16f && swatch.population >= totalPopulation * 0.04f
|
||||
}
|
||||
|
||||
return swatches.maxByOrNull { swatch ->
|
||||
return colorfulSwatches.ifEmpty { swatches }.maxByOrNull { swatch ->
|
||||
android.graphics.Color.colorToHSV(swatch.rgb, hsv)
|
||||
val saturation = hsv[1]
|
||||
val value = hsv[2]
|
||||
@@ -145,16 +180,30 @@ private fun selectRepresentativeColor(palette: Palette): Int {
|
||||
}?.rgb ?: fallback
|
||||
}
|
||||
|
||||
private fun deriveAmbientPalette(source: Color, themeDark: Boolean): PosterImmersivePalette {
|
||||
private fun deriveAmbientPalette(source: Color, themeDark: Boolean, vivid: Boolean): PosterImmersivePalette {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(source.toArgb(), hsv)
|
||||
|
||||
val hue = hsv[0]
|
||||
val sourceSaturation = hsv[1]
|
||||
val sourceValue = hsv[2]
|
||||
val neutral = sourceSaturation < 0.04f
|
||||
|
||||
if (vivid) {
|
||||
val saturation = if (neutral) 0f else (sourceSaturation * 0.92f).coerceAtMost(0.8f)
|
||||
val value = if (themeDark) 0.12f + sourceValue * 0.22f else 0.64f + sourceValue * 0.20f
|
||||
val hero = readableAmbientColor(hsvColor(hue, saturation, value), themeDark)
|
||||
val endpoint = if (themeDark) android.graphics.Color.BLACK else android.graphics.Color.WHITE
|
||||
return PosterImmersivePalette(
|
||||
hero = hero,
|
||||
pageTop = Color(ColorUtils.setAlphaComponent(ColorUtils.blendARGB(hero.toArgb(), endpoint, 0.02f), 255)),
|
||||
pageBottom = Color(ColorUtils.setAlphaComponent(ColorUtils.blendARGB(hero.toArgb(), endpoint, 0.05f), 255)),
|
||||
isDark = themeDark,
|
||||
)
|
||||
}
|
||||
|
||||
return if (themeDark) {
|
||||
val heroSaturation = (sourceSaturation * 0.58f).coerceIn(0.09f, 0.32f)
|
||||
val heroSaturation = if (neutral) 0f else (sourceSaturation * 0.58f).coerceIn(0.09f, 0.32f)
|
||||
val heroValue = (0.055f + sourceValue * 0.13f).coerceIn(0.065f, 0.17f)
|
||||
val pageSaturation = heroSaturation * 0.86f
|
||||
val pageValue = (heroValue * 0.76f).coerceIn(0.035f, 0.13f)
|
||||
@@ -169,9 +218,9 @@ private fun deriveAmbientPalette(source: Color, themeDark: Boolean): PosterImmer
|
||||
// Keep the dominant hue but lift it into a gentle pastel range. A deep-blue
|
||||
// poster therefore becomes a soft blue surface rather than white/gray.
|
||||
// Slightly richer than before, while still staying clean/light in light theme.
|
||||
val heroSaturation = (sourceSaturation * 0.38f + 0.035f).coerceIn(0.06f, 0.21f)
|
||||
val heroSaturation = if (neutral) 0f else (sourceSaturation * 0.38f + 0.035f).coerceIn(0.06f, 0.21f)
|
||||
val heroValue = (0.77f + sourceValue * 0.07f).coerceIn(0.79f, 0.85f)
|
||||
val pageSaturation = (heroSaturation * 0.68f).coerceIn(0.04f, 0.15f)
|
||||
val pageSaturation = if (neutral) 0f else (heroSaturation * 0.68f).coerceIn(0.04f, 0.15f)
|
||||
val pageValue = (heroValue + 0.06f).coerceIn(0.86f, 0.91f)
|
||||
|
||||
PosterImmersivePalette(
|
||||
@@ -183,5 +232,18 @@ private fun deriveAmbientPalette(source: Color, themeDark: Boolean): PosterImmer
|
||||
}
|
||||
}
|
||||
|
||||
private fun readableAmbientColor(source: Color, themeDark: Boolean): Color {
|
||||
val text = if (themeDark) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
val secondary = ColorUtils.setAlphaComponent(text, 179)
|
||||
val endpoint = if (themeDark) android.graphics.Color.BLACK else android.graphics.Color.WHITE
|
||||
var background = source.toArgb()
|
||||
// Preserve as much chroma as possible while also keeping 70%-alpha body
|
||||
// captions readable. The calculation runs once per artwork, off the UI thread.
|
||||
while (ColorUtils.calculateContrast(ColorUtils.compositeColors(secondary, background), background) < 4.5) {
|
||||
background = ColorUtils.setAlphaComponent(ColorUtils.blendARGB(background, endpoint, 0.04f), 255)
|
||||
}
|
||||
return Color(background)
|
||||
}
|
||||
|
||||
private fun hsvColor(hue: Float, saturation: Float, value: Float): Color =
|
||||
Color(android.graphics.Color.HSVToColor(255, floatArrayOf(hue, saturation.coerceIn(0f, 1f), value.coerceIn(0f, 1f))))
|
||||
@@ -14,6 +14,7 @@ import com.kyant.backdrop.effects.colorControls
|
||||
import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.effects.vibrancy
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.highlight.HighlightStyle
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import com.kyant.shapes.RoundedRectangle
|
||||
import androidx.compose.foundation.border
|
||||
@@ -591,7 +592,12 @@ fun LiquidGlassPopupOverlay(
|
||||
)
|
||||
}
|
||||
},
|
||||
highlight = { Highlight.Plain.copy(alpha = 0.58f) },
|
||||
highlight = {
|
||||
Highlight(
|
||||
alpha = 0.60f,
|
||||
style = HighlightStyle.Default(angle = 180f, falloff = 1.1f),
|
||||
)
|
||||
},
|
||||
shadow = { Shadow(alpha = 0.18f) },
|
||||
onDrawSurface = { drawRect(surface) },
|
||||
)
|
||||
|
||||
@@ -192,6 +192,9 @@ fun LumaMediaCard(
|
||||
titleSize: TextUnit = LumaTypography.body,
|
||||
titleWeight: FontWeight = FontWeight.Medium,
|
||||
titleSpacing: Dp = LumaSpacing.xs,
|
||||
subtitle: String? = null,
|
||||
subtitleColor: Color = titleColor.copy(alpha = 0.6f),
|
||||
subtitleStarColor: Color = subtitleColor,
|
||||
progress: Float? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
overlay: @Composable BoxScope.() -> Unit = {},
|
||||
@@ -283,6 +286,29 @@ fun LumaMediaCard(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
val parts = subtitle.split(" ", limit = 2)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = parts[0],
|
||||
color = subtitleStarColor,
|
||||
fontSize = (titleSize.value - 1).sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (parts.size > 1) {
|
||||
Text(
|
||||
text = " ${parts[1]}",
|
||||
color = subtitleColor,
|
||||
fontSize = (titleSize.value - 1).sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +382,7 @@ fun LumaSettingRow(
|
||||
role = Role.Button,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = LumaSpacing.md, vertical = 14.dp),
|
||||
.padding(horizontal = LumaSpacing.md, vertical = LumaSpacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
@@ -373,12 +399,12 @@ fun LumaSettingRow(
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Spacer(Modifier.width(LumaSpacing.sm))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
title,
|
||||
color = LumaColors.text,
|
||||
fontSize = 15.sp,
|
||||
fontSize = LumaTypography.body,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
@@ -413,7 +439,7 @@ fun LumaPageTopBar(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 18.dp, vertical = 14.dp),
|
||||
.padding(horizontal = LumaSpacing.md, vertical = LumaSpacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LumaIconButton(
|
||||
@@ -509,6 +535,7 @@ fun LumaSeekBar(
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
down.consume()
|
||||
val horizontalInset = 7.dp.toPx()
|
||||
val usableWidth = (size.width - horizontalInset * 2f).coerceAtLeast(1f)
|
||||
var gestureValue = ((down.position.x - horizontalInset) / usableWidth).coerceIn(0f, 1f)
|
||||
@@ -521,8 +548,8 @@ fun LumaSeekBar(
|
||||
if (change.pressed) {
|
||||
gestureValue = ((change.position.x - horizontalInset) / usableWidth).coerceIn(0f, 1f)
|
||||
currentOnValueChange(gestureValue)
|
||||
change.consume()
|
||||
}
|
||||
change.consume()
|
||||
}
|
||||
dragging = false
|
||||
currentOnValueChangeFinished(gestureValue)
|
||||
|
||||
@@ -17,6 +17,9 @@ import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -33,6 +36,8 @@ import com.kyant.backdrop.effects.colorControls
|
||||
import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.effects.vibrancy
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.highlight.HighlightStyle
|
||||
import com.kyant.shapes.Capsule
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import com.kyant.shapes.RoundedRectangle
|
||||
|
||||
@@ -174,6 +179,25 @@ object LumaColors {
|
||||
get() = if (isDark) Color.White.copy(alpha = 0.07f) else Color.White.copy(alpha = 0.24f)
|
||||
val glassShadow: Color
|
||||
get() = if (isDark) Color.Black.copy(alpha = 0.32f) else Color(0xFF344054).copy(alpha = 0.14f)
|
||||
/** Sweep-gradient border for iOS 26 liquid glass edge effect */
|
||||
val glassBorderGradient: Brush
|
||||
get() = if (isDark) {
|
||||
Brush.sweepGradient(
|
||||
0.00f to Color.White.copy(alpha = 0.35f),
|
||||
0.25f to Color.White.copy(alpha = 0.10f),
|
||||
0.50f to Color.White.copy(alpha = 0.20f),
|
||||
0.75f to Color.White.copy(alpha = 0.10f),
|
||||
1.00f to Color.White.copy(alpha = 0.35f),
|
||||
)
|
||||
} else {
|
||||
Brush.sweepGradient(
|
||||
0.00f to Color.White.copy(alpha = 0.65f),
|
||||
0.25f to Color.White.copy(alpha = 0.18f),
|
||||
0.50f to Color.White.copy(alpha = 0.35f),
|
||||
0.75f to Color.White.copy(alpha = 0.18f),
|
||||
1.00f to Color.White.copy(alpha = 0.65f),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Settings popup colors ──
|
||||
val settingsPopupBg: Color get() = if (isDark) Color(0xFF111821) else Color(0xFFF4F7FA)
|
||||
@@ -192,28 +216,34 @@ fun Modifier.lumaFallbackGlass(
|
||||
val fill = when {
|
||||
liquidFallback && light && selected -> Brush.verticalGradient(
|
||||
listOf(
|
||||
Color.White.copy(alpha = 0.46f),
|
||||
Color(0xFFE5EFFA).copy(alpha = 0.38f),
|
||||
Color.White.copy(alpha = 0.28f),
|
||||
Color.White.copy(alpha = 0.50f),
|
||||
Color(0xFFE0ECF8).copy(alpha = 0.42f),
|
||||
Color(0xFFD4E2F0).copy(alpha = 0.36f),
|
||||
Color(0xFFE8F0F8).copy(alpha = 0.32f),
|
||||
Color.White.copy(alpha = 0.26f),
|
||||
),
|
||||
)
|
||||
liquidFallback && light -> Brush.verticalGradient(
|
||||
listOf(
|
||||
Color.White.copy(alpha = 0.32f),
|
||||
Color(0xFFE4EDF7).copy(alpha = 0.27f),
|
||||
Color.White.copy(alpha = 0.20f),
|
||||
Color.White.copy(alpha = 0.36f),
|
||||
Color(0xFFE4EDF7).copy(alpha = 0.30f),
|
||||
Color(0xFFD8E4EF).copy(alpha = 0.26f),
|
||||
Color(0xFFE2EBF4).copy(alpha = 0.28f),
|
||||
Color.White.copy(alpha = 0.18f),
|
||||
),
|
||||
)
|
||||
liquidFallback && selected -> Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFF60758A).copy(alpha = 0.70f),
|
||||
Color(0xFF304253).copy(alpha = 0.64f),
|
||||
Color(0xFF657B92).copy(alpha = 0.74f),
|
||||
Color(0xFF405870).copy(alpha = 0.68f),
|
||||
Color(0xFF2D4258).copy(alpha = 0.62f),
|
||||
),
|
||||
)
|
||||
liquidFallback -> Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFF44586C).copy(alpha = 0.60f),
|
||||
Color(0xFF233140).copy(alpha = 0.66f),
|
||||
Color(0xFF4A6278).copy(alpha = 0.64f),
|
||||
Color(0xFF2E4254).copy(alpha = 0.58f),
|
||||
Color(0xFF1E3040).copy(alpha = 0.52f),
|
||||
),
|
||||
)
|
||||
light && selected -> Brush.verticalGradient(
|
||||
@@ -262,25 +292,38 @@ fun Modifier.lumaFallbackGlass(
|
||||
},
|
||||
)
|
||||
}
|
||||
val topHighlight = if (light) {
|
||||
Color.White.copy(
|
||||
alpha = when {
|
||||
liquidFallback && selected -> 0.34f
|
||||
liquidFallback -> 0.24f
|
||||
selected -> 0.28f
|
||||
else -> 0.18f
|
||||
},
|
||||
)
|
||||
// Pre-compute highlight alphas (size not available outside DrawScope)
|
||||
val highlightTop = if (light) {
|
||||
when {
|
||||
liquidFallback && selected -> 0.50f
|
||||
liquidFallback -> 0.38f
|
||||
selected -> 0.42f
|
||||
else -> 0.30f
|
||||
}
|
||||
} else {
|
||||
Color.White.copy(
|
||||
alpha = when {
|
||||
liquidFallback && selected -> 0.14f
|
||||
liquidFallback -> 0.08f
|
||||
selected -> 0.10f
|
||||
else -> 0.06f
|
||||
},
|
||||
)
|
||||
when {
|
||||
liquidFallback && selected -> 0.22f
|
||||
liquidFallback -> 0.14f
|
||||
selected -> 0.16f
|
||||
else -> 0.10f
|
||||
}
|
||||
}
|
||||
val highlightMid = if (light) {
|
||||
when {
|
||||
liquidFallback && selected -> 0.12f
|
||||
liquidFallback -> 0.08f
|
||||
selected -> 0.10f
|
||||
else -> 0.06f
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
liquidFallback && selected -> 0.06f
|
||||
liquidFallback -> 0.04f
|
||||
selected -> 0.05f
|
||||
else -> 0.02f
|
||||
}
|
||||
}
|
||||
val highlightEndFraction = if (light) 0.55f else 0.50f
|
||||
return this
|
||||
.shadow(
|
||||
elevation = if (selected) 2.dp else 4.dp,
|
||||
@@ -294,16 +337,17 @@ fun Modifier.lumaFallbackGlass(
|
||||
neutralVeil?.let { Modifier.background(it, shape) } ?: Modifier,
|
||||
)
|
||||
.drawWithCache {
|
||||
val highlight = Brush.verticalGradient(
|
||||
listOf(topHighlight, Color.Transparent),
|
||||
val topHighlight = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = highlightTop),
|
||||
Color.White.copy(alpha = highlightMid),
|
||||
Color.Transparent,
|
||||
),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.72f,
|
||||
endY = size.height * highlightEndFraction,
|
||||
)
|
||||
onDrawWithContent {
|
||||
// The fallback surface is a material layer. Its highlight must
|
||||
// sit behind the content; drawing it after drawContent makes
|
||||
// labels and icons look washed out on devices without Backdrop.
|
||||
drawRect(highlight)
|
||||
drawRect(topHighlight)
|
||||
drawContent()
|
||||
}
|
||||
}
|
||||
@@ -311,6 +355,68 @@ fun Modifier.lumaFallbackGlass(
|
||||
.clip(shape)
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS 26 liquid glass gradient border.
|
||||
* Uses [LumaColors.glassBorderGradient] to draw a sweep-gradient stroke
|
||||
* that simulates light refraction around the glass surface edge.
|
||||
*/
|
||||
fun Modifier.liquidBorder(
|
||||
shape: Shape,
|
||||
strokeWidth: Dp = 0.8.dp,
|
||||
gradientBrush: Brush = LumaColors.glassBorderGradient,
|
||||
): Modifier = this.then(
|
||||
Modifier.drawWithCache {
|
||||
val strokePx = strokeWidth.toPx()
|
||||
// Compute corner radii by probing the shape at each corner
|
||||
val cornerRadii = when (shape) {
|
||||
is Capsule -> floatArrayOf(size.minDimension / 2f)
|
||||
is androidx.compose.foundation.shape.RoundedCornerShape -> floatArrayOf(
|
||||
shape.topStart.toPx(size, this@drawWithCache),
|
||||
shape.topEnd.toPx(size, this@drawWithCache),
|
||||
shape.bottomEnd.toPx(size, this@drawWithCache),
|
||||
shape.bottomStart.toPx(size, this@drawWithCache),
|
||||
)
|
||||
else -> floatArrayOf(0f)
|
||||
}
|
||||
val inset = strokePx / 2f
|
||||
val roundedRect = when {
|
||||
cornerRadii.size == 1 -> {
|
||||
val r = cornerRadii[0]
|
||||
androidx.compose.ui.geometry.RoundRect(
|
||||
inset, inset,
|
||||
size.width - inset, size.height - inset,
|
||||
CornerRadius(r, r),
|
||||
)
|
||||
}
|
||||
cornerRadii.size == 4 -> {
|
||||
androidx.compose.ui.geometry.RoundRect(
|
||||
left = inset, top = inset,
|
||||
right = size.width - inset, bottom = size.height - inset,
|
||||
topLeftCornerRadius = CornerRadius(cornerRadii[0], cornerRadii[0]),
|
||||
topRightCornerRadius = CornerRadius(cornerRadii[1], cornerRadii[1]),
|
||||
bottomRightCornerRadius = CornerRadius(cornerRadii[2], cornerRadii[2]),
|
||||
bottomLeftCornerRadius = CornerRadius(cornerRadii[3], cornerRadii[3]),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
androidx.compose.ui.geometry.RoundRect(
|
||||
inset, inset,
|
||||
size.width - inset, size.height - inset,
|
||||
)
|
||||
}
|
||||
}
|
||||
onDrawBehind {
|
||||
drawRoundRect(
|
||||
brush = gradientBrush,
|
||||
topLeft = androidx.compose.ui.geometry.Offset(roundedRect.left, roundedRect.top),
|
||||
size = androidx.compose.ui.geometry.Size(roundedRect.width, roundedRect.height),
|
||||
cornerRadius = CornerRadius(cornerRadii.getOrElse(0) { 0f }),
|
||||
style = Stroke(width = strokePx, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@Immutable
|
||||
enum class LumaGlassLevel {
|
||||
Clear,
|
||||
@@ -382,7 +488,13 @@ fun Modifier.lumaGlass(
|
||||
)
|
||||
}
|
||||
},
|
||||
highlight = if (highlight) ({ Highlight.Plain.copy(alpha = 0.58f) }) else null,
|
||||
highlight = if (highlight) ({
|
||||
// iOS 26: directional gradient highlight for consistent glass depth
|
||||
Highlight(
|
||||
alpha = 0.62f,
|
||||
style = HighlightStyle.Default(angle = 180f, falloff = 1.1f),
|
||||
)
|
||||
}) else null,
|
||||
shadow = { Shadow(alpha = spec.shadowAlpha) },
|
||||
onDrawSurface = { drawRect(surfaceTint) },
|
||||
)
|
||||
|
||||
@@ -28,16 +28,17 @@ class DampedDragAnimation(
|
||||
val onDrag: DampedDragAnimation.(size: IntSize, dragAmount: Offset) -> Unit,
|
||||
) {
|
||||
|
||||
// iOS 26 liquid glass: more fluid, bouncy spring physics
|
||||
private val valueAnimationSpec =
|
||||
spring(1f, 1000f, visibilityThreshold)
|
||||
spring(0.65f, 260f, visibilityThreshold)
|
||||
private val velocityAnimationSpec =
|
||||
spring(0.5f, 300f, visibilityThreshold * 10f)
|
||||
spring(0.55f, 240f, visibilityThreshold * 10f)
|
||||
private val pressProgressAnimationSpec =
|
||||
spring(1f, 1000f, 0.001f)
|
||||
spring(0.7f, 280f, 0.001f)
|
||||
private val scaleXAnimationSpec =
|
||||
spring(0.6f, 250f, 0.001f)
|
||||
spring(0.55f, 200f, 0.001f)
|
||||
private val scaleYAnimationSpec =
|
||||
spring(0.7f, 250f, 0.001f)
|
||||
spring(0.6f, 200f, 0.001f)
|
||||
|
||||
private val valueAnimation =
|
||||
Animatable(initialValue, visibilityThreshold)
|
||||
|
||||
+13
-1
@@ -39,7 +39,19 @@ class InteractiveHighlight(
|
||||
Modifier.drawWithContent {
|
||||
val progress = pressProgressAnimation.value
|
||||
if (progress > 0f) {
|
||||
// iOS 26: subtle ambient glow + directional top gradient
|
||||
drawRect(Color.White.copy(0.08f * progress), blendMode = BlendMode.Plus)
|
||||
// Top specular line for glass thickness illusion
|
||||
val topGlow = Brush.verticalGradient(
|
||||
listOf(
|
||||
Color.White.copy(alpha = 0.12f * progress),
|
||||
Color.Transparent,
|
||||
),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.40f,
|
||||
)
|
||||
drawRect(topGlow, blendMode = BlendMode.Plus)
|
||||
// Touch-following radial highlight
|
||||
val center = position(size, positionAnimation.value).let {
|
||||
Offset(
|
||||
it.x.fastCoerceIn(0f, size.width),
|
||||
@@ -49,7 +61,7 @@ class InteractiveHighlight(
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.15f * progress),
|
||||
Color.White.copy(alpha = 0.18f * progress),
|
||||
Color.Transparent,
|
||||
),
|
||||
center = center,
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -45,6 +46,7 @@ import com.example.myapplication.ui.design.LumaColors
|
||||
import com.example.myapplication.ui.design.LumaGlassRuntime
|
||||
import com.example.myapplication.ui.design.LumaSpacing
|
||||
import com.example.myapplication.ui.design.lumaFallbackGlass
|
||||
import com.example.myapplication.ui.design.liquidBorder
|
||||
import com.kyant.backdrop.Backdrop
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
||||
@@ -54,6 +56,7 @@ import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.effects.vibrancy
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.highlight.HighlightStyle
|
||||
import com.kyant.backdrop.shadow.InnerShadow
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import com.kyant.shapes.Capsule
|
||||
@@ -136,7 +139,8 @@ fun LiquidBottomTabs(
|
||||
currentIndex = targetIndex
|
||||
animateToValue(targetIndex.toFloat())
|
||||
animationScope.launch {
|
||||
offsetAnimation.animateTo(0f, spring(1f, 300f, 0.5f))
|
||||
// iOS 26: softer spring for smoother panel offset recovery
|
||||
offsetAnimation.animateTo(0f, spring(0.6f, 250f, 0.5f))
|
||||
}
|
||||
},
|
||||
onDrag = { _, dragAmount ->
|
||||
@@ -234,6 +238,13 @@ fun LiquidBottomTabs(
|
||||
blur(8f.dp.toPx())
|
||||
lens(24f.dp.toPx(), 24f.dp.toPx())
|
||||
},
|
||||
highlight = {
|
||||
// iOS 26: stronger top-down directional gradient highlight
|
||||
Highlight(
|
||||
alpha = 0.72f,
|
||||
style = HighlightStyle.Default(angle = 180f, falloff = 1.2f),
|
||||
)
|
||||
},
|
||||
layerBlock = {
|
||||
val progress = dampedDragAnimation.pressProgress
|
||||
val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress)
|
||||
@@ -243,7 +254,7 @@ fun LiquidBottomTabs(
|
||||
onDrawSurface = { drawRect(containerColor) },
|
||||
)
|
||||
.then(if (pressHighlight) interactiveHighlight.modifier else Modifier)
|
||||
.border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule())
|
||||
.liquidBorder(Capsule(), strokeWidth = 0.8.dp)
|
||||
.height(height)
|
||||
.then(interactiveHighlight.gestureModifier)
|
||||
.then(dampedDragAnimation.modifier)
|
||||
@@ -278,7 +289,11 @@ fun LiquidBottomTabs(
|
||||
lens(24f.dp.toPx() * progress, 24f.dp.toPx() * progress)
|
||||
},
|
||||
highlight = {
|
||||
Highlight.Default.copy(alpha = dampedDragAnimation.pressProgress * pressHighlightAlpha)
|
||||
// Ghost layer: subtle directional highlight for depth
|
||||
Highlight(
|
||||
alpha = dampedDragAnimation.pressProgress * pressHighlightAlpha * 0.5f,
|
||||
style = HighlightStyle.Default(angle = 180f, falloff = 1.3f),
|
||||
)
|
||||
},
|
||||
onDrawSurface = { drawRect(containerColor) },
|
||||
)
|
||||
@@ -307,37 +322,56 @@ fun LiquidBottomTabs(
|
||||
shape = { Capsule() },
|
||||
effects = {
|
||||
val progress = dampedDragAnimation.pressProgress
|
||||
// Static lens for persistent depth + press-enhanced chromatic aberration
|
||||
lens(
|
||||
10f.dp.toPx() * progress,
|
||||
14f.dp.toPx() * progress,
|
||||
(6f + 10f * progress).dp.toPx(),
|
||||
(8f + 8f * progress).dp.toPx(),
|
||||
chromaticAberration = true,
|
||||
)
|
||||
},
|
||||
highlight = {
|
||||
Highlight.Default.copy(alpha = dampedDragAnimation.pressProgress * pressHighlightAlpha)
|
||||
// iOS 26: directional gradient highlight with stronger top-down light
|
||||
Highlight(
|
||||
width = 0.8f.dp,
|
||||
alpha = (0.15f + 0.55f * dampedDragAnimation.pressProgress) * pressHighlightAlpha,
|
||||
style = HighlightStyle.Default(angle = 180f, falloff = 1.5f),
|
||||
)
|
||||
},
|
||||
shadow = { Shadow(alpha = dampedDragAnimation.pressProgress) },
|
||||
shadow = { Shadow(alpha = dampedDragAnimation.pressProgress * 0.9f) },
|
||||
innerShadow = {
|
||||
val progress = dampedDragAnimation.pressProgress
|
||||
InnerShadow(radius = 8.dp * progress, alpha = progress)
|
||||
InnerShadow(radius = 8.dp * progress, alpha = progress * 0.85f)
|
||||
},
|
||||
layerBlock = {
|
||||
scaleX = dampedDragAnimation.scaleX
|
||||
scaleY = dampedDragAnimation.scaleY
|
||||
// iOS 26: enhanced velocity-based liquid deformation
|
||||
val velocity = dampedDragAnimation.velocity / 10f
|
||||
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f)
|
||||
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f)
|
||||
scaleX /= 1f - (velocity * 0.85f).fastCoerceIn(-0.22f, 0.22f)
|
||||
scaleY *= 1f - (velocity * 0.30f).fastCoerceIn(-0.22f, 0.22f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
val progress = dampedDragAnimation.pressProgress
|
||||
// Base tint
|
||||
drawRect(
|
||||
if (isLightTheme) Color.Black.copy(alpha = 0.10f)
|
||||
else Color.White.copy(alpha = 0.10f),
|
||||
alpha = 1f - progress,
|
||||
)
|
||||
drawRect(Color.Black.copy(alpha = 0.03f * progress))
|
||||
// iOS 26: top-edge specular line for glass thickness illusion
|
||||
val specular = Brush.verticalGradient(
|
||||
listOf(
|
||||
Color.White.copy(alpha = 0.12f * (1f - progress)),
|
||||
Color.Transparent,
|
||||
),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.35f,
|
||||
)
|
||||
drawRect(specular)
|
||||
},
|
||||
)
|
||||
.liquidBorder(Capsule(), strokeWidth = 0.6.dp)
|
||||
.height(height - 8.dp)
|
||||
.fillMaxWidth(1f / tabsCount),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="nav_home">Home</string>
|
||||
<string name="nav_records">Vision</string>
|
||||
<string name="nav_records">Records</string>
|
||||
<string name="nav_library">Server</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="settings_title">Settings</string>
|
||||
@@ -234,6 +234,8 @@
|
||||
<string name="player_playback_tools">Playback tools</string>
|
||||
<string name="player_chapters">Chapter markers</string>
|
||||
<string name="player_no_chapters">No chapter markers are available</string>
|
||||
<string name="player_no_subtitles">No subtitles available</string>
|
||||
<string name="player_no_audio">No audio tracks available</string>
|
||||
<string name="player_chapter_at">Chapter %1$s</string>
|
||||
<string name="player_skip_intro">Skip intro</string>
|
||||
<string name="player_skip_outro">Skip outro</string>
|
||||
@@ -242,12 +244,12 @@
|
||||
<string name="player_play_next_episode">Play next episode</string>
|
||||
<string name="player_cancel_continuous_playback">Not now</string>
|
||||
<string name="player_close">Close</string>
|
||||
<string name="player_seek_backward">Seek back 10 seconds</string>
|
||||
<string name="player_seek_backward">Seek back %1$d seconds</string>
|
||||
<string name="player_previous_episode">Previous episode</string>
|
||||
<string name="player_play">Play</string>
|
||||
<string name="player_pause">Pause</string>
|
||||
<string name="player_next_episode">Next episode</string>
|
||||
<string name="player_seek_forward">Seek forward 10 seconds</string>
|
||||
<string name="player_seek_forward">Seek forward %1$d seconds</string>
|
||||
<string name="player_progress">Playback progress</string>
|
||||
<string name="player_brightness">Brightness</string>
|
||||
<string name="player_volume">Volume</string>
|
||||
@@ -398,4 +400,27 @@
|
||||
<string name="error_network">Network error: %s</string>
|
||||
<string name="error_unknown">Unknown</string>
|
||||
<string name="clear_search">Clear search</string>
|
||||
<!-- Playback Settings -->
|
||||
<string name="settings_playback_settings">Playback Settings</string>
|
||||
<string name="settings_default_speed">Default playback speed</string>
|
||||
<string name="settings_long_press_speed">Long-press speed</string>
|
||||
<string name="settings_seek_seconds">Double-tap seek</string>
|
||||
<string name="settings_auto_skip_intro">Auto skip intro</string>
|
||||
<string name="settings_auto_skip_intro_sub">Auto skip when reaching intro</string>
|
||||
<string name="settings_auto_skip_outro">Auto skip outro</string>
|
||||
<string name="settings_auto_skip_outro_sub">Auto skip when reaching outro</string>
|
||||
<string name="settings_confirm_continuous">Confirm before next episode</string>
|
||||
<string name="settings_confirm_continuous_sub">Show confirmation on continuous play</string>
|
||||
<string name="settings_progress_report_interval">Progress report interval</string>
|
||||
<string name="player_skip_intro_float">Skip intro</string>
|
||||
<string name="player_skip_outro_float">Skip outro</string>
|
||||
<string name="player_speed_menu">Playback speed</string>
|
||||
<string name="player_speed_value">%1$sx</string>
|
||||
<string name="player_auto_skip_intro">Auto skip intro</string>
|
||||
<string name="player_auto_skip_outro">Auto skip outro</string>
|
||||
<string name="player_mark_intro">Mark intro</string>
|
||||
<string name="player_mark_outro">Mark outro</string>
|
||||
<string name="player_mark_intro_sub">Mark current time as intro end</string>
|
||||
<string name="player_mark_outro_sub">Mark current time as outro start</string>
|
||||
<string name="player_mark_position">Current: %1$s</string>
|
||||
</resources>
|
||||
@@ -2,7 +2,7 @@
|
||||
<resources>
|
||||
<string name="app_name">LumaPlayer</string>
|
||||
<string name="nav_home">首页</string>
|
||||
<string name="nav_records">Vision</string>
|
||||
<string name="nav_records">记录</string>
|
||||
<string name="nav_library">服务器</string>
|
||||
<string name="nav_settings">设置</string>
|
||||
<string name="settings_title">设置</string>
|
||||
@@ -234,6 +234,8 @@
|
||||
<string name="player_playback_tools">播放工具</string>
|
||||
<string name="player_chapters">章节标记</string>
|
||||
<string name="player_no_chapters">该媒体没有章节标记</string>
|
||||
<string name="player_no_subtitles">该媒体没有可用字幕</string>
|
||||
<string name="player_no_audio">该媒体没有可用音轨</string>
|
||||
<string name="player_chapter_at">章节 %1$s</string>
|
||||
<string name="player_skip_intro">跳过片头</string>
|
||||
<string name="player_skip_outro">跳过片尾</string>
|
||||
@@ -242,12 +244,12 @@
|
||||
<string name="player_play_next_episode">播放下一集</string>
|
||||
<string name="player_cancel_continuous_playback">暂不继续播放</string>
|
||||
<string name="player_close">关闭</string>
|
||||
<string name="player_seek_backward">后退 10 秒</string>
|
||||
<string name="player_seek_backward">后退 %1$d 秒</string>
|
||||
<string name="player_previous_episode">上一集</string>
|
||||
<string name="player_play">播放</string>
|
||||
<string name="player_pause">暂停</string>
|
||||
<string name="player_next_episode">下一集</string>
|
||||
<string name="player_seek_forward">前进 10 秒</string>
|
||||
<string name="player_seek_forward">前进 %1$d 秒</string>
|
||||
<string name="player_progress">播放进度</string>
|
||||
<string name="player_brightness">亮度</string>
|
||||
<string name="player_volume">音量</string>
|
||||
@@ -398,4 +400,27 @@
|
||||
<string name="error_network">网络错误: %s</string>
|
||||
<string name="error_unknown">未知</string>
|
||||
<string name="clear_search">清除搜索</string>
|
||||
<!-- 播放设置 -->
|
||||
<string name="settings_playback_settings">播放设置</string>
|
||||
<string name="settings_default_speed">默认播放速度</string>
|
||||
<string name="settings_long_press_speed">长按倍速</string>
|
||||
<string name="settings_seek_seconds">双击快进/快退</string>
|
||||
<string name="settings_auto_skip_intro">自动跳过片头</string>
|
||||
<string name="settings_auto_skip_intro_sub">播放到片头位置时自动跳过</string>
|
||||
<string name="settings_auto_skip_outro">自动跳过片尾</string>
|
||||
<string name="settings_auto_skip_outro_sub">播放到片尾位置时自动跳过</string>
|
||||
<string name="settings_confirm_continuous">播放下集前确认</string>
|
||||
<string name="settings_confirm_continuous_sub">连播时弹出确认面板</string>
|
||||
<string name="settings_progress_report_interval">进度回报间隔</string>
|
||||
<string name="player_skip_intro_float">跳过片头</string>
|
||||
<string name="player_skip_outro_float">跳过片尾</string>
|
||||
<string name="player_speed_menu">播放速度</string>
|
||||
<string name="player_speed_value">%1$sx</string>
|
||||
<string name="player_auto_skip_intro">自动跳过片头</string>
|
||||
<string name="player_auto_skip_outro">自动跳过片尾</string>
|
||||
<string name="player_mark_intro">标记片头</string>
|
||||
<string name="player_mark_outro">标记片尾</string>
|
||||
<string name="player_mark_intro_sub">将当前时间标记为片头结束</string>
|
||||
<string name="player_mark_outro_sub">将当前时间标记为片尾开始</string>
|
||||
<string name="player_mark_position">当前位置: %1$s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.example.myapplication.navigation
|
||||
|
||||
import androidx.navigationevent.NavigationEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PredictiveBackMotionTest {
|
||||
@Test
|
||||
fun leftGestureKeepsItsDirectionWhenTheGestureCompletes() {
|
||||
val motion = PredictiveBackMotion()
|
||||
motion.start("detail", NavigationEvent.EDGE_LEFT)
|
||||
|
||||
assertEquals(1, motion.directionFor("detail"))
|
||||
// The back stack may already point to home while detail is still exiting.
|
||||
motion.onEntryResumed("home")
|
||||
assertEquals(1, motion.directionFor("detail"))
|
||||
assertNull(motion.directionFor("home"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rightGestureContinuesLeftInsteadOfChangingDirectionOnRelease() {
|
||||
val motion = PredictiveBackMotion()
|
||||
motion.start("detail", NavigationEvent.EDGE_RIGHT)
|
||||
|
||||
assertEquals(-1, motion.directionFor("detail"))
|
||||
motion.onEntryResumed("home")
|
||||
assertEquals(-1, motion.directionFor("detail"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelKeepsTheGestureMotionUntilTheOriginalEntryResumes() {
|
||||
val motion = PredictiveBackMotion()
|
||||
motion.start("detail", NavigationEvent.EDGE_RIGHT)
|
||||
|
||||
assertEquals(-1, motion.directionFor("detail"))
|
||||
motion.onEntryResumed("detail")
|
||||
assertNull(motion.directionFor("detail"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLaterGestureDoesNotReuseTheEarlierEntryOrDirection() {
|
||||
val motion = PredictiveBackMotion()
|
||||
motion.start("detail-1", NavigationEvent.EDGE_RIGHT)
|
||||
motion.start("detail-2", NavigationEvent.EDGE_LEFT)
|
||||
|
||||
assertNull(motion.directionFor("detail-1"))
|
||||
assertEquals(1, motion.directionFor("detail-2"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.example.myapplication.screens
|
||||
|
||||
import androidx.compose.runtime.AbstractApplier
|
||||
import androidx.compose.runtime.Composition
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Recomposer
|
||||
import androidx.compose.runtime.saveable.LocalSaveableStateRegistry
|
||||
import androidx.compose.runtime.saveable.SaveableStateRegistry
|
||||
import com.example.myapplication.data.EmbyItem
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [28])
|
||||
class HomeHeroStateTest {
|
||||
private val items = (1..50).map { EmbyItem(id = "item-$it", name = "Title $it", type = "Movie") }
|
||||
|
||||
@Test
|
||||
fun returningToHomeRestoresTheSameHeroArtworkAndOrder() {
|
||||
val first = HeroComposition()
|
||||
val original = first.render(items)
|
||||
val savedState = first.saveAndDispose()
|
||||
|
||||
val restored = HeroComposition(savedState)
|
||||
try {
|
||||
assertEquals(original, restored.render(items))
|
||||
} finally {
|
||||
restored.saveAndDispose()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadingTheSnapshotAgainKeepsTheSameHeroOrder() {
|
||||
val home = HeroComposition()
|
||||
try {
|
||||
val original = home.render(items)
|
||||
assertEquals(original, home.render(items.map { it.copy() }))
|
||||
assertEquals(8, original.size)
|
||||
assertEquals(8, original.map { it.id }.distinct().size)
|
||||
} finally {
|
||||
home.saveAndDispose()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitRefreshChoosesAnotherHeroOrder() {
|
||||
val home = HeroComposition()
|
||||
try {
|
||||
val original = home.render(items)
|
||||
assertNotEquals(original, home.render(items, refreshToken = 1))
|
||||
assertTrue(home.render(emptyList(), refreshToken = 2).isEmpty())
|
||||
} finally {
|
||||
home.saveAndDispose()
|
||||
}
|
||||
}
|
||||
|
||||
private class HeroComposition(savedState: Map<String, List<Any?>>? = null) {
|
||||
private val registry = SaveableStateRegistry(savedState) { true }
|
||||
private val recomposer = Recomposer(EmptyCoroutineContext)
|
||||
private val composition = Composition(object : AbstractApplier<Unit>(Unit) {
|
||||
override fun insertTopDown(index: Int, instance: Unit) = Unit
|
||||
override fun insertBottomUp(index: Int, instance: Unit) = Unit
|
||||
override fun remove(index: Int, count: Int) = Unit
|
||||
override fun move(from: Int, to: Int, count: Int) = Unit
|
||||
override fun onClear() = Unit
|
||||
}, recomposer)
|
||||
|
||||
fun render(items: List<EmbyItem>, refreshToken: Int = 0): List<EmbyItem> {
|
||||
var result = emptyList<EmbyItem>()
|
||||
composition.setContent {
|
||||
CompositionLocalProvider(LocalSaveableStateRegistry provides registry) {
|
||||
result = rememberHomeHeroItems(items, "server-1", refreshToken)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun saveAndDispose(): Map<String, List<Any?>> {
|
||||
val saved = registry.performSave()
|
||||
composition.dispose()
|
||||
recomposer.cancel()
|
||||
return saved
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,65 @@ class HomeScreenDataTest {
|
||||
assertEquals("movie-1", continueWatchingDetailId(movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heroStartsOnFirstItemWithRoomToSwipeBothWays() {
|
||||
val page = heroInitialPage(8)
|
||||
|
||||
org.junit.Assert.assertTrue(page > 8)
|
||||
assertEquals(0, heroItemIndex(page, 8))
|
||||
assertEquals(7, heroItemIndex(page - 1, 8))
|
||||
assertEquals(6, heroItemIndex(page - 2, 8))
|
||||
assertEquals(1, heroItemIndex(page + 1, 8))
|
||||
assertEquals(2, heroItemIndex(page + 2, 8))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heroWrapsFromLastItemToFirstInOnePage() {
|
||||
val lastPage = heroInitialPage(8) + 7
|
||||
|
||||
assertEquals(7, heroItemIndex(lastPage, 8))
|
||||
assertEquals(0, heroItemIndex(lastPage + 1, 8))
|
||||
assertEquals(1, heroItemIndex(lastPage + 2, 8))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heroHandlesEmptySingleAndTwoItemLists() {
|
||||
assertEquals(0, heroInitialPage(0))
|
||||
assertEquals(0, heroInitialPage(1))
|
||||
assertEquals(0, heroItemIndex(0, 0))
|
||||
assertEquals(0, heroItemIndex(100, 1))
|
||||
val page = heroInitialPage(2)
|
||||
assertEquals(1, heroItemIndex(page - 1, 2))
|
||||
assertEquals(1, heroItemIndex(page + 1, 2))
|
||||
assertEquals(0, heroItemIndex(page + 2, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heroFlipFollowsTheDragInBothDirections() {
|
||||
assertEquals(0f, heroFlipRotation(0f)!!, 0.001f)
|
||||
assertEquals(-45f, heroFlipRotation(0.25f)!!, 0.001f)
|
||||
assertEquals(45f, heroFlipRotation(-0.25f)!!, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heroFlipChangesFacesEdgeOnWithoutShowingMirroredArtwork() {
|
||||
assertEquals(-90f, heroFlipRotation(0.5f)!!, 0.001f)
|
||||
assertEquals(90f, heroFlipRotation(-0.5f)!!, 0.001f)
|
||||
assertNull(heroFlipRotation(0.501f))
|
||||
assertNull(heroFlipRotation(-0.501f))
|
||||
assertNull(heroFlipRotation(1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heroFlipRetracesTheSameAnglesWhenADragIsCancelled() {
|
||||
val rotationBeforeRelease = heroFlipRotation(0.3f)!!
|
||||
val rotationDuringReturn = heroFlipRotation(0.15f)!!
|
||||
|
||||
assertEquals(-54f, rotationBeforeRelease, 0.001f)
|
||||
assertEquals(-27f, rotationDuringReturn, 0.001f)
|
||||
assertEquals(0f, heroFlipRotation(0f)!!, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun elapsedPlaybackFormatsMilliseconds() {
|
||||
assertEquals("12:34", formatPlaybackElapsed(754_000L))
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.myapplication.screens
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class VideoPlayerTimeFormatTest {
|
||||
@Test
|
||||
fun alwaysDisplaysHoursMinutesAndSeconds() {
|
||||
assertEquals("00:00:00", formatMs(0L))
|
||||
assertEquals("00:00:09", formatMs(9_999L))
|
||||
assertEquals("00:01:00", formatMs(60_000L))
|
||||
assertEquals("00:59:59", formatMs(3_599_999L))
|
||||
assertEquals("01:00:00", formatMs(3_600_000L))
|
||||
assertEquals("12:34:56", formatMs(45_296_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownDurationDisplaysZero() {
|
||||
assertEquals("00:00:00", formatMs(-1L))
|
||||
assertEquals("00:00:00", formatMs(Long.MIN_VALUE + 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hoursDoNotWrapAfterOneDay() {
|
||||
assertEquals("25:00:00", formatMs(90_000_000L))
|
||||
assertEquals("100:00:00", formatMs(360_000_000L))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.example.myapplication.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color as AndroidColor
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import kotlin.math.abs
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [28])
|
||||
class ImmersiveBackgroundTest {
|
||||
@Test
|
||||
fun croppedBackdropUsesVisibleCenterInsteadOfHiddenSides() {
|
||||
val bitmap = Bitmap.createBitmap(144, 48, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(AndroidColor.GREEN)
|
||||
for (x in 48 until 96) {
|
||||
for (y in 0 until 48) setPixel(x, y, AndroidColor.BLUE)
|
||||
}
|
||||
}
|
||||
for (dark in listOf(false, true)) {
|
||||
val hue = hsv(extractPosterPalette(bitmap, dark).hero)[0]
|
||||
assertTrue("Visible blue artwork produced hue $hue", abs(hue - 240f) < 12f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun neutralArtworkStaysNeutralInBothThemes() {
|
||||
for (source in listOf(0xFF000000, 0xFF808080, 0xFFFFFFFF)) {
|
||||
for (dark in listOf(false, true)) {
|
||||
for (vivid in listOf(false, true)) {
|
||||
val palette = extractPosterPalette(solid(source.toInt()), dark, vivid = vivid)
|
||||
for (color in listOf(palette.hero, palette.pageTop, palette.pageBottom)) {
|
||||
assertTrue("Neutral artwork became ${color.toArgb().toUInt().toString(16)} (dark=$dark, vivid=$vivid)", hsv(color)[1] < 0.02f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun solidArtworkKeepsItsHueIncludingWarmColors() {
|
||||
for (hue in listOf(0f, 20f, 40f, 120f, 210f, 280f)) {
|
||||
val source = AndroidColor.HSVToColor(floatArrayOf(hue, 0.8f, 0.8f))
|
||||
for (dark in listOf(false, true)) {
|
||||
for (vivid in listOf(false, true)) {
|
||||
val palette = extractPosterPalette(solid(source), dark, vivid = vivid)
|
||||
val actual = hsv(palette.hero)[0]
|
||||
val difference = abs(actual - hue)
|
||||
assertTrue("Expected hue $hue, got $actual (dark=$dark, vivid=$vivid)", minOf(difference, 360f - difference) < 12f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothThemesKeepReadableTextAndCorrectBrightness() {
|
||||
for (source in listOf(0xFFCC3333, 0xFF33CC33, 0xFF3333CC, 0xFF808080)) {
|
||||
for (dark in listOf(false, true)) {
|
||||
val palette = extractPosterPalette(solid(source.toInt()), dark)
|
||||
assertEquals(dark, palette.isDark)
|
||||
for (color in listOf(palette.hero, palette.pageTop, palette.pageBottom)) {
|
||||
val text = if (dark) AndroidColor.WHITE else AndroidColor.BLACK
|
||||
assertTrue(ColorUtils.calculateContrast(text, color.toArgb()) >= 4.5)
|
||||
assertTrue(if (dark) hsv(color)[2] < 0.25f else hsv(color)[2] > 0.75f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vividHomeKeepsMoreColorThroughTheLowerPage() {
|
||||
for (source in listOf(0xFFCC3333, 0xFF33CC33, 0xFF3333CC)) {
|
||||
for (dark in listOf(false, true)) {
|
||||
val artwork = solid(source.toInt())
|
||||
val subtle = extractPosterPalette(artwork, dark)
|
||||
val vivid = extractPosterPalette(artwork, dark, vivid = true)
|
||||
val heroSaturation = hsv(vivid.hero)[1]
|
||||
assertTrue("Vivid background is still washed out (dark=$dark)", heroSaturation > hsv(subtle.hero)[1] + 0.08f)
|
||||
assertTrue("Lower content lost the artwork color", hsv(vivid.pageBottom)[1] >= heroSaturation * 0.85f)
|
||||
val luminanceChange = abs(ColorUtils.calculateLuminance(vivid.hero.toArgb()) - ColorUtils.calculateLuminance(vivid.pageBottom.toArgb()))
|
||||
assertTrue("Hero and lower content have a visible brightness break: $luminanceChange", luminanceChange < 0.07)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vividHomeKeepsPrimaryAndSecondaryTextReadable() {
|
||||
for (hue in listOf(0f, 20f, 40f, 120f, 210f, 240f, 280f)) {
|
||||
val artwork = solid(AndroidColor.HSVToColor(floatArrayOf(hue, 0.8f, 0.8f)))
|
||||
for (dark in listOf(false, true)) {
|
||||
val palette = extractPosterPalette(artwork, dark, vivid = true)
|
||||
val primary = if (dark) AndroidColor.WHITE else AndroidColor.BLACK
|
||||
val secondary = ColorUtils.setAlphaComponent(primary, 179)
|
||||
for (color in listOf(palette.hero, palette.pageTop, palette.pageBottom)) {
|
||||
val background = color.toArgb()
|
||||
assertTrue(ColorUtils.calculateContrast(primary, background) >= 4.5)
|
||||
assertTrue("Secondary text lost contrast at hue $hue (dark=$dark)", ColorUtils.calculateContrast(ColorUtils.compositeColors(secondary, background), background) >= 4.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun samplingFollowsTheNarrowVisibleHeroCrop() {
|
||||
val bitmap = Bitmap.createBitmap(144, 48, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(AndroidColor.GREEN)
|
||||
for (x in 60 until 84) {
|
||||
for (y in 0 until 48) setPixel(x, y, AndroidColor.BLUE)
|
||||
}
|
||||
}
|
||||
val palette = extractPosterPalette(bitmap, themeDark = false, vivid = true, imageAspectRatio = 0.5f)
|
||||
assertTrue("Hidden green sides overrode visible blue artwork", abs(hsv(palette.hero)[0] - 240f) < 12f)
|
||||
}
|
||||
|
||||
private fun solid(color: Int): Bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(color)
|
||||
}
|
||||
|
||||
private fun hsv(color: Color): FloatArray = FloatArray(3).also {
|
||||
AndroidColor.colorToHSV(color.toArgb(), it)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
param(
|
||||
[ValidateSet('DetailReturn', 'Tabs', 'RapidTabs')]
|
||||
[string]$Scenario = 'DetailReturn',
|
||||
[int]$Iterations = 3,
|
||||
[int]$WarmupIterations = 3,
|
||||
[double]$MaxP95Ms = 32,
|
||||
[string]$Label = 'navigation',
|
||||
[string]$Serial,
|
||||
[string]$AdbPath = "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe"
|
||||
)
|
||||
|
||||
# Run on an unlocked device with the app's home page and media already loaded.
|
||||
# Use the same device, build type, refresh rate, and scenario for before/after runs.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($Iterations -lt 1 -or $WarmupIterations -lt 1) { throw 'Iteration counts must be positive.' }
|
||||
if ($Label -notmatch '^[a-zA-Z0-9_-]+$') { throw 'Label must be a simple filename.' }
|
||||
$deviceArgs = if ($Serial) { @('-s', $Serial) } else { @() }
|
||||
$outputDirectory = Join-Path $PSScriptRoot '..\build\perf'
|
||||
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
|
||||
|
||||
function Invoke-Adb([string[]]$AdbArguments) {
|
||||
$output = & $AdbPath @deviceArgs @AdbArguments
|
||||
if ($LASTEXITCODE -ne 0) { throw "adb failed: $($AdbArguments -join ' ')" }
|
||||
return $output
|
||||
}
|
||||
|
||||
function Get-Window {
|
||||
Invoke-Adb @('shell', 'uiautomator', 'dump', '/sdcard/luma-perf-window.xml') | Out-Null
|
||||
return [xml](Invoke-Adb @('shell', 'cat', '/sdcard/luma-perf-window.xml'))
|
||||
}
|
||||
|
||||
function Get-Center($Node) {
|
||||
if (-not $Node -or $Node.bounds -notmatch '^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$') {
|
||||
throw 'Required control is not visible. Open the loaded home page first.'
|
||||
}
|
||||
return @([int](([int]$Matches[1] + [int]$Matches[3]) / 2),
|
||||
[int](([int]$Matches[2] + [int]$Matches[4]) / 2))
|
||||
}
|
||||
|
||||
function Tap($Point) {
|
||||
Invoke-Adb @('shell', 'input', 'tap', "$($Point[0])", "$($Point[1])") | Out-Null
|
||||
}
|
||||
|
||||
$window = Get-Window
|
||||
$homeNode = $window.SelectSingleNode('//node[@content-desc="首页" or @content-desc="Home"]')
|
||||
$homePoint = Get-Center $homeNode
|
||||
$homeMarker = $window.SelectSingleNode('//node[@text="继续观看" or @text="Continue Watching" or @text="媒体库" or @text="My Media"]')
|
||||
if (-not $homeMarker) { throw 'A loaded home media section must be visible before measuring.' }
|
||||
if ($Scenario -eq 'DetailReturn') {
|
||||
$resumeLabel = $window.SelectSingleNode('//node[@text="继续观看" or @text="Continue Watching"]')
|
||||
if (-not $resumeLabel) { throw 'A visible Continue Watching row is required for the detail return scenario.' }
|
||||
$resumeBottom = (Get-Center $resumeLabel)[1]
|
||||
$mediaNode = $window.SelectNodes('//node[@clickable="true" and descendant::node[@content-desc!=""]]') |
|
||||
Where-Object { (Get-Center $_)[1] -gt $resumeBottom -and (Get-Center $_)[1] -lt $homePoint[1] } |
|
||||
Select-Object -First 1
|
||||
$media = Get-Center $mediaNode
|
||||
} else {
|
||||
$tabs = @('记录', '服务器', '设置', '首页') | ForEach-Object {
|
||||
,(Get-Center $window.SelectSingleNode("//node[@content-desc='$_']"))
|
||||
}
|
||||
}
|
||||
|
||||
$samples = @()
|
||||
# Warm the same navigation paths before comparing builds.
|
||||
for ($iteration = 1 - $WarmupIterations; $iteration -le $Iterations; $iteration++) {
|
||||
if ($Scenario -eq 'DetailReturn') {
|
||||
Tap $media
|
||||
$detailReady = $false
|
||||
for ($attempt = 0; $attempt -lt 5; $attempt++) {
|
||||
$detail = Get-Window
|
||||
if ($detail.SelectSingleNode('//node[@content-desc="返回" or @content-desc="Back"]')) {
|
||||
$detailReady = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $detailReady) {
|
||||
throw 'Media detail did not open; refusing to measure another screen.'
|
||||
}
|
||||
}
|
||||
Invoke-Adb @('shell', 'dumpsys', 'gfxinfo', 'com.example.myapplication', 'reset') | Out-Null
|
||||
if ($Scenario -eq 'DetailReturn') {
|
||||
Invoke-Adb @('shell', 'input', 'keyevent', '4') | Out-Null
|
||||
Start-Sleep -Milliseconds 900
|
||||
} else {
|
||||
$pause = if ($Scenario -eq 'RapidTabs') { '0.12' } else { '0.6' }
|
||||
$commands = $tabs | ForEach-Object { "input tap $($_[0]) $($_[1]); sleep $pause" }
|
||||
Invoke-Adb @('shell', ($commands -join '; ')) | Out-Null
|
||||
Start-Sleep -Milliseconds 900
|
||||
}
|
||||
$stats = Invoke-Adb @('shell', 'dumpsys', 'gfxinfo', 'com.example.myapplication', 'framestats')
|
||||
$text = $stats -join "`n"
|
||||
$frames = [int]([regex]::Match($text, 'Total frames rendered: (\d+)').Groups[1].Value)
|
||||
if ($frames -lt 10) { throw "Only $frames frames captured; the scenario did not run." }
|
||||
$returned = Get-Window
|
||||
if (-not $returned.SelectSingleNode("//node[@text='$($homeMarker.text)']")) {
|
||||
throw 'The scenario did not return to the original home media section.'
|
||||
}
|
||||
if ($iteration -le 0) { continue }
|
||||
$stats | Set-Content -LiteralPath (Join-Path $outputDirectory "$Label-$Scenario-$iteration.txt")
|
||||
$samples += [pscustomobject]@{
|
||||
Iteration = $iteration
|
||||
Frames = $frames
|
||||
P95Ms = [int]([regex]::Match($text, '(?m)^95th percentile: (\d+)ms').Groups[1].Value)
|
||||
JankyFrames = [int]([regex]::Match($text, '(?m)^Janky frames: (\d+)').Groups[1].Value)
|
||||
JankPercent = [double]([regex]::Match($text, '(?m)^Janky frames: \d+ \(([\d.]+)%\)').Groups[1].Value)
|
||||
}
|
||||
}
|
||||
$samples | Format-Table -AutoSize
|
||||
$medianP95 = ($samples.P95Ms | Sort-Object)[[int][math]::Floor($samples.Count / 2)]
|
||||
$summary = [pscustomobject]@{ Scenario = $Scenario; MedianP95Ms = $medianP95; MaxP95Ms = $MaxP95Ms; Samples = $samples }
|
||||
$summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $outputDirectory "$Label-$Scenario.json")
|
||||
if ($medianP95 -gt $MaxP95Ms) { throw "Median P95 ${medianP95}ms exceeds ${MaxP95Ms}ms." }
|
||||
Write-Output "PASS: median P95 ${medianP95}ms <= ${MaxP95Ms}ms."
|
||||
Reference in new issue
Block a user