Compare commits

..
18 Commits
Author SHA1 Message Date
beyond 21f347e588 优化页面转场、预测返回与首页沉浸取色
复用底栏并恢复首页轮播状态,减少页面返回时的重复计算。统一预测返回的跟手、完成及取消动效,保留左右手势方向。增强首页海报主色并衔接下方背景,修复裁剪、暖色和灰度取色,补充回归测试及性能测量脚本。

验证:assembleDebug、testDebugUnitTest(64项)及 git diff --check 通过。
2026-10-26 16:34:22 +08:00
beyond 4dd07f84e7 小调整 2026-09-12 14:15:36 +08:00
beyond 0dfb3788e8 首页优化 2026-09-07 17:13:05 +08:00
beyond dd1c62f0f0 小优化 2026-09-07 16:34:29 +08:00
beyond 391797d377 优化播放器功能,增加播放器设置 2026-09-06 23:26:49 +08:00
beyond e3e8120e8b 优化播放器界面 2026-09-06 19:47:26 +08:00
beyondandClaude Opus 5 29601803f5 修复: 进度条seek完成后同步currentPositionMs防止抖动
- 重置isSeeking前先将currentPositionMs同步到seek目标位置
- 防止轮询循环在player追上之前用旧位置覆盖显示

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 19:04:34 +08:00
beyondandClaude Opus 5 614538dc98 修复: LumaSeekBar重复modifier参数
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 19:01:06 +08:00
beyondandClaude Opus 5 84d4914d8a 播放器UI优化: 上移顶部、缩小胶囊、修复进度条抖动
- 顶部padding从LumaSpacing.md(16dp)减到LumaSpacing.xs(8dp)
- 胶囊框padding从16dp/2dp减到12dp/0dp,字体12sp→11sp
- 进度条抖动修复:seek完成后延迟300ms重置isSeeking
  防止轮询循环在player追上之前覆盖currentPositionMs
- 新增seekCompletedVersion计数器触发延迟重置

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:57:56 +08:00
beyondandClaude Opus 5 702e9810d0 播放器: 时间/网速/电量布局调整
- PlayerStatusBar拆分为PlayerTimeDisplay和PlayerNetworkInfo两个独立组件
- 时间显示在关闭按钮上方(左侧)
- 网速+电量显示在播放工具按钮上方(右侧)
- 减少间距从8dp到2dp

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:47:45 +08:00
beyondandClaude Opus 5 7c991c04be 修复: 剧集播放器logo使用series ID获取
- 剧集的logo在series级别,不是episode级别
- 构建logo URL时检测是否为剧集(seriesId != id)
- 剧集使用seriesId请求logo,电影使用自身id
- 确保电视剧和电影都能正确显示emby艺术文字

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:37:59 +08:00
beyondandClaude Opus 5 807445eafa 修复: 更新selectedItem以正确传递logo给播放器
- 获取series logo后同时更新item和selectedItem
- target = selectedItem ?: item,仅更新item不够
- 确保播放器的titleLogoUrl正确获取logo URL

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:31:46 +08:00
beyondandClaude Opus 5 d7726424d9 修复: 播放器series logo获取改为独立LaunchedEffect
- 将series logo获取拆分为独立的LaunchedEffect
- 先加载episode item,再异步获取series logo
- 避免在同一个LaunchedEffect中修改state导致重组问题

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:24:13 +08:00
beyondandClaude Opus 5 fdf14f7dd2 播放器: 剧集无logo时从剧集(series)获取logo
- PlayerDestination加载剧集后检查是否有logo
- 若剧集无logo,额外请求series item获取其logo tag
- 合并到剧集的imageTags中,确保播放器显示正确的艺术文字

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:21:13 +08:00
beyondandClaude Opus 5 ea6a751e72 播放器: 无logo时显示剧名而非集名
- VideoPlayerScreen新增seriesName参数
- 无logo图片时显示seriesName(剧名)而非title(集名)
- AppNavHost传递target.seriesName给播放器

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:15:24 +08:00
beyondandClaude Opus 5 0bb52f3ec8 播放器: BatteryIcon颜色统一到LumaColors + 清理空行
- BatteryIcon: 硬编码颜色→LumaColors.offline/ratingStar/online
- 清理sed替换产生的多余空行

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:09:56 +08:00
beyondandClaude Opus 5 34e2ef6bd7 播放器优化: 菜单状态合并、连播偏好持久化、间距统一
- 新增PlayerMenu枚举,替代5个独立boolean状态(showSubtitleMenu等)
- 菜单切换逻辑统一为activeMenu.toggle模式
- BackHandler简化为activeMenu != PlayerMenu.None
- 菜单onDismiss统一关闭activeMenu
- confirmBeforeContinuousPlayback持久化到SharedPreferences
- 顶部间距16.dp→LumaSpacing.md
- 清理sed替换产生的多余空行

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:02:00 +08:00
beyondandClaude Opus 5 ebd6963bf5 UI细节优化: 间距令牌、字体统一、LazyList key
- LumaComponents: LumaSettingRow/LumaPageTopBar 间距改用LumaSpacing令牌
- LumaComponents: LumaSettingRow标题fontSize改用LumaTypography.body
- HomeScreen: SectionTitle ExtraBold→Bold统一, MyMediaGrid LazyRow添加key
- VideoPlayerScreen: 播放器剧集列表LazyColumn添加key

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:36:32 +08:00
34 changed files with 2053 additions and 539 deletions

No files matched your search

+3 -2
View File
@@ -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)
@@ -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),
)
+28 -3
View File
@@ -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>
+28 -3
View File
@@ -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)
}
}
+113
View File
@@ -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."