diff --git a/shared/src/commonMain/kotlin/dev/piko/data/auth/PikoUserPreferences.kt b/shared/src/commonMain/kotlin/dev/piko/data/auth/PikoUserPreferences.kt index 28393e2a..21835ece 100644 --- a/shared/src/commonMain/kotlin/dev/piko/data/auth/PikoUserPreferences.kt +++ b/shared/src/commonMain/kotlin/dev/piko/data/auth/PikoUserPreferences.kt @@ -287,6 +287,11 @@ interface PikoUserPreferences { suspend fun setPlayerBoostSpeed(speed: Float) /** 信息流 30 秒片段播完后自动顺延续播当前视频后续内容,默认开启。跟着设置同步。 */ + /** Device-local clip volume and mute state, stored together. */ + var clipAudioSettings: String + get() = "1.0,false" + set(value) {} + val clipAutoExtendFlow: Flow suspend fun setClipAutoExtend(enabled: Boolean) diff --git a/shared/src/commonMain/kotlin/dev/piko/shared/data/PikoDriveRepository.kt b/shared/src/commonMain/kotlin/dev/piko/shared/data/PikoDriveRepository.kt index 23bccc3b..d8b5580e 100644 --- a/shared/src/commonMain/kotlin/dev/piko/shared/data/PikoDriveRepository.kt +++ b/shared/src/commonMain/kotlin/dev/piko/shared/data/PikoDriveRepository.kt @@ -2151,6 +2151,34 @@ open class PikoDriveRepository( emit(FolderUsage(files, bytes, FolderUsage.Progress.COMPLETE)) }.flowOn(Dispatchers.Default) + private val previewDurationFetches = Semaphore(2) + private val previewDurations = LinkedHashMap() + + /** Only visible video cards request details; successful metadata is also stored by fetchDetail. */ + suspend fun previewDurationSeconds(file: FileStat): Long? { + if (!file.isPlayableVideo() || file.isFolder) return null + contentMetadataOf(file)?.durationMillis?.takeIf { it > 0 }?.let { return it / 1000 } + file.params["duration"]?.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 1 }?.let { return it.toLong() } + if (LocalLocation.isLocal(file.id) || file.isVaulted || file.id.startsWith("import-")) return null + val owner = clientManager.currentClient.value ?: return null + val key = "${owner.account}:${file.id}" + return previewDurationFetches.withPermit { + synchronized(previewDurations) { + if (previewDurations.containsKey(key)) return@withPermit previewDurations[key] + } + if (clientManager.currentClient.value !== owner) return@withPermit null + val detail = runSuspendCatching { fetchDetail(file.id, owner) }.getOrNull() ?: return@withPermit null + val seconds = detail.params["duration"]?.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 1 }?.toLong() + ?: detail.medias.firstOrNull { it.isOrigin }?.video?.duration?.takeIf { it > 0 } + ?: detail.medias.firstNotNullOfOrNull { it.video?.duration?.takeIf { d -> d > 0 } } + synchronized(previewDurations) { + previewDurations[key] = seconds + if (previewDurations.size > 512) previewDurations.remove(previewDurations.keys.first()) + } + seconds + } + } + suspend fun getFileDetail(fileId: String): Result = withContext(Dispatchers.Default) { runSuspendCatching { fetchDetail(fileId, client) } } diff --git a/shared/src/commonMain/kotlin/dev/piko/shared/preferences/StoredPikoPreferences.kt b/shared/src/commonMain/kotlin/dev/piko/shared/preferences/StoredPikoPreferences.kt index c02e844e..70a8d0c8 100644 --- a/shared/src/commonMain/kotlin/dev/piko/shared/preferences/StoredPikoPreferences.kt +++ b/shared/src/commonMain/kotlin/dev/piko/shared/preferences/StoredPikoPreferences.kt @@ -39,6 +39,10 @@ class StoredPikoPreferences( this.language.value = language } + override var clipAudioSettings: String + get() = settings.get("player.clipAudio", "1.0,false") + set(value) { settings.set("player.clipAudio", value) } + private val clipAutoExtend = MutableStateFlow(settings.get("player.clipAutoExtend", "true").toBoolean()) override val clipAutoExtendFlow: Flow = clipAutoExtend.asStateFlow() override suspend fun setClipAutoExtend(enabled: Boolean) { diff --git a/shared/src/commonMain/kotlin/dev/piko/shared/state/ClipFeedSession.kt b/shared/src/commonMain/kotlin/dev/piko/shared/state/ClipFeedSession.kt index 605b8e32..08fd6fe5 100644 --- a/shared/src/commonMain/kotlin/dev/piko/shared/state/ClipFeedSession.kt +++ b/shared/src/commonMain/kotlin/dev/piko/shared/state/ClipFeedSession.kt @@ -82,6 +82,7 @@ class ClipFeedSession( private val media: PikoMediaRepository, private val cacheStore: PikoCacheStore?, private val scope: CoroutineScope, + private val audioPreferences: dev.piko.data.auth.PikoUserPreferences? = null, ) { var root by mutableStateOf(null) private set @@ -134,7 +135,28 @@ class ClipFeedSession( private set /** 静音。放在会话上而不是页面上:打开完整播放器或换到独立窗口时页面会重建,静音要跟着走。 */ - var muted by mutableStateOf(false) + private val savedAudio = audioPreferences?.clipAudioSettings?.split(",").orEmpty() + var muted by mutableStateOf(savedAudio.getOrNull(1) == "true") + private set + + var volume by mutableStateOf(savedAudio.firstOrNull()?.toFloatOrNull()?.takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: 1f) + private set + + private fun saveAudio() { + audioPreferences?.clipAudioSettings = "$volume,$muted" + } + + fun toggleMute() { + if (muted && volume == 0f) volume = 1f + muted = !muted + saveAudio() + } + + fun updateVolume(value: Float) { + volume = value.coerceIn(0f, 1f) + muted = volume == 0f + saveAudio() + } /** 控件连同顶栏全部藏起。与 [muted] 同理放在会话上,关掉信息流时清零,不存盘。 */ var immersive by mutableStateOf(false) diff --git a/shared/src/desktopTest/kotlin/dev/piko/shared/data/PreviewDurationTest.kt b/shared/src/desktopTest/kotlin/dev/piko/shared/data/PreviewDurationTest.kt new file mode 100644 index 00000000..a945e519 --- /dev/null +++ b/shared/src/desktopTest/kotlin/dev/piko/shared/data/PreviewDurationTest.kt @@ -0,0 +1,32 @@ +package dev.piko.shared.data + +import dev.piko.shared.smoke.FakePikPakServer +import io.github.nihildigit.pikpak.FileStat +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PreviewDurationTest { + @Test fun missingListingDurationUsesDetailAndCachesResult() = runBlocking { + val server = FakePikPakServer() + val node = server.addVideo("sample.m2ts", durationSeconds = 2510.0) + val repo = PikoDriveRepository(server.provider()) + val file = FileStat(id = node.id, name = node.name) + assertEquals(2510L, repo.previewDurationSeconds(file)) + val calls = server.calls.size + assertEquals(2510L, repo.previewDurationSeconds(file)) + assertEquals(calls, server.calls.size) + } + @Test fun existingDurationAvoidsNetworkAndMissingDetailIsNotInvented() = runBlocking { + val server = FakePikPakServer() + val repo = PikoDriveRepository(server.provider()) + assertEquals(90L, repo.previewDurationSeconds(FileStat(id = "known", name = "known.mp4", params = mapOf("duration" to "90")))) + assertEquals(0, server.calls.size) + val node = server.addFile("unknown.mp4") + assertNull(repo.previewDurationSeconds(FileStat(id = node.id, name = node.name))) + val calls = server.calls.size + assertNull(repo.previewDurationSeconds(FileStat(id = node.id, name = node.name))) + assertEquals(calls, server.calls.size) + } +} diff --git a/shared/src/desktopTest/kotlin/dev/piko/shared/state/ClipFeedSessionTest.kt b/shared/src/desktopTest/kotlin/dev/piko/shared/state/ClipFeedSessionTest.kt index eb2ec864..5ece2b88 100644 --- a/shared/src/desktopTest/kotlin/dev/piko/shared/state/ClipFeedSessionTest.kt +++ b/shared/src/desktopTest/kotlin/dev/piko/shared/state/ClipFeedSessionTest.kt @@ -24,6 +24,48 @@ import kotlin.test.assertTrue import kotlinx.serialization.json.Json class ClipFeedSessionTest { + @Test fun audioSettingsSurviveSessionRecreation() = runSession { server, drive, _, cache -> + val prefs = object : dev.piko.data.auth.PikoUserPreferences by MemoryPreferences() { + override var clipAudioSettings = "1.0,false" + } + val testScope = CoroutineScope(SupervisorJob()) + try { + fun create() = ClipFeedSession(server.provider(), drive, PikoMediaRepository(server.provider()), cache, testScope, prefs) + val first = create() + first.updateVolume(0.27f) + first.toggleMute() + first.close() + val second = create() + assertEquals(0.27f, second.volume) + assertTrue(second.muted) + second.toggleMute() + second.close() + val third = create() + assertEquals(0.27f, third.volume) + assertFalse(third.muted) + third.close() + } finally { testScope.cancel() } + } + + @Test fun volumeAndMutePreserveLevelAndRecoverFromZero() = runSession { _, _, session, _ -> + session.updateVolume(0.35f) + session.toggleMute() + assertTrue(session.muted) + assertEquals(0.35f, session.volume) + session.toggleMute() + assertFalse(session.muted) + assertEquals(0.35f, session.volume) + session.updateVolume(0f) + assertTrue(session.muted) + session.toggleMute() + assertFalse(session.muted) + assertEquals(1f, session.volume) + session.updateVolume(2f) + assertEquals(1f, session.volume) + session.updateVolume(-1f) + assertEquals(0f, session.volume) + } + private val path = listOf(PikoDriveRepository.ROOT_BREADCRUMB) private fun runSession(block: suspend (FakePikPakServer, PikoDriveRepository, ClipFeedSession, MemoryCacheStore) -> Unit) { diff --git a/ui/src/commonMain/kotlin/dev/piko/ui/PikoServices.kt b/ui/src/commonMain/kotlin/dev/piko/ui/PikoServices.kt index 7cb47b3c..a6ee4ca7 100644 --- a/ui/src/commonMain/kotlin/dev/piko/ui/PikoServices.kt +++ b/ui/src/commonMain/kotlin/dev/piko/ui/PikoServices.kt @@ -443,7 +443,7 @@ class PikoServices( // 主线程且与进程同寿:打开完整播放器时随机片段页可能被销毁,队列要留着回来接着看 val clipFeedSession: ClipFeedSession by lazy { - ClipFeedSession(clientManager, driveRepository, mediaRepository, cacheStore, CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)) + ClipFeedSession(clientManager, driveRepository, mediaRepository, cacheStore, CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), preferences) } init { diff --git a/ui/src/commonMain/kotlin/dev/piko/ui/components/FileListItem.kt b/ui/src/commonMain/kotlin/dev/piko/ui/components/FileListItem.kt index 9e93ea00..34b1b2c7 100644 --- a/ui/src/commonMain/kotlin/dev/piko/ui/components/FileListItem.kt +++ b/ui/src/commonMain/kotlin/dev/piko/ui/components/FileListItem.kt @@ -259,6 +259,7 @@ fun FileListItem( supporting = { Column { FileItemInformation(item, showMetadata = tags.isEmpty() || !file.isFolder) + FileAddedTime(file) if (!locationLabel.isNullOrEmpty()) { // 路径从头截断:离命中项最近的几级目录最有辨识度 Text( diff --git a/ui/src/commonMain/kotlin/dev/piko/ui/components/FilePresentation.kt b/ui/src/commonMain/kotlin/dev/piko/ui/components/FilePresentation.kt index 897ac69d..a1c74610 100644 --- a/ui/src/commonMain/kotlin/dev/piko/ui/components/FilePresentation.kt +++ b/ui/src/commonMain/kotlin/dev/piko/ui/components/FilePresentation.kt @@ -250,3 +250,21 @@ internal fun fileItemLongPress(action: (() -> Unit)?): (() -> Unit)? { } } } + +@Composable +internal fun FileAddedTime(file: FileStat) { + val time = remember(file.createdTime) { + runCatching { + java.time.OffsetDateTime.parse(file.createdTime) + .atZoneSameInstant(java.time.ZoneId.systemDefault()) + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + }.getOrNull() + } ?: return + Text( + text = tr("加入时间:", "Added: ", "追加日時:", "추가 시간: ", "加入時間:") + time, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/ui/src/commonMain/kotlin/dev/piko/ui/screens/clips/ClipChrome.kt b/ui/src/commonMain/kotlin/dev/piko/ui/screens/clips/ClipChrome.kt index 22dcc4d8..ded50faa 100644 --- a/ui/src/commonMain/kotlin/dev/piko/ui/screens/clips/ClipChrome.kt +++ b/ui/src/commonMain/kotlin/dev/piko/ui/screens/clips/ClipChrome.kt @@ -55,6 +55,9 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Surface +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.TextButton +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.TooltipAnchorPosition @@ -139,6 +142,8 @@ internal fun ClipFeedTopBar( onHideChrome: (() -> Unit)? = null, landscapeLocked: Boolean? = null, onToggleLandscape: () -> Unit = {}, + volume: Float = 1f, + onVolumeChange: (Float) -> Unit = {}, ) { val buttonSize = if (compact) 40.dp else 48.dp // 在主窗口里全屏或停在右侧一栏时,这一行贴着窗口右上角,窗口按钮接在关闭后面 @@ -187,14 +192,28 @@ internal fun ClipFeedTopBar( shortcut = "A", ) } - ChromeIconButton( - icon = if (muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp, - label = if (muted) tr("取消静音", "Unmute", "ミュートを解除", "음소거 해제", "取消靜音") else tr("静音", "Mute", "ミュート", "음소거", "靜音"), - onClick = onToggleMute, - size = buttonSize, - tooltip = true, - shortcut = "M", - ) + Box { + var showVolume by remember { mutableStateOf(false) } + ChromeIconButton( + icon = if (muted || volume == 0f) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp, + label = tr("音量", "Volume", "音量", "음량", "音量"), + onClick = { showVolume = !showVolume }, + size = buttonSize, + tooltip = true, + shortcut = "M", + ) + DropdownMenu(expanded = showVolume, onDismissRequest = { showVolume = false }) { + Column(Modifier.widthIn(min = 200.dp, max = 240.dp).padding(horizontal = 16.dp)) { + Text(tr("音量", "Volume", "音量", "음량", "音量") + " ${(volume * 100).toInt()}%") + Slider(value = volume, onValueChange = onVolumeChange, valueRange = 0f..1f) + TextButton(onClick = { + if (muted && volume == 0f) onVolumeChange(1f) else onToggleMute() + }) { + Text(if (muted) tr("取消静音", "Unmute", "ミュートを解除", "음소거 해제", "取消靜音") else tr("静音", "Mute", "ミュート", "음소거", "靜音")) + } + } + } + } if (onDock != null) { // 置顶与播放窗口同一个按钮:一边刷一边浏览网盘时,独立窗口一点主窗口就被盖到后面 val window = LocalFramelessWindow.current diff --git a/ui/src/commonMain/kotlin/dev/piko/ui/screens/clips/ClipFeedScreen.kt b/ui/src/commonMain/kotlin/dev/piko/ui/screens/clips/ClipFeedScreen.kt index 03f944df..a859aa0c 100644 --- a/ui/src/commonMain/kotlin/dev/piko/ui/screens/clips/ClipFeedScreen.kt +++ b/ui/src/commonMain/kotlin/dev/piko/ui/screens/clips/ClipFeedScreen.kt @@ -234,7 +234,9 @@ fun ClipFeedScreen( ClipFeedTopBar( title = session.scopeLabel, muted = session.muted, - onToggleMute = { session.muted = !session.muted }, + volume = session.volume, + onVolumeChange = session::updateVolume, + onToggleMute = { session.toggleMute() }, onClose = { if (inLandscape) session.landscape = false else onBackClick() }, compact = compact, autoAdvance = session.autoAdvance, @@ -384,6 +386,8 @@ private fun ClipPager( val chromeAlpha by animateFloatAsState(if (scrubbing) 0f else 1f, motion.defaultEffectsSpec()) val muted = session.muted val latestMuted by rememberUpdatedState(muted) + val volume = session.volume + val latestVolume by rememberUpdatedState(volume) // 开播前先缓存好的段数;到 WARM_UP_CLIPS 或超时才开播 var warmed by remember { mutableIntStateOf(0) } var warmUpDone by remember { mutableStateOf(false) } @@ -508,7 +512,7 @@ private fun ClipPager( playWhenReady = false, ) // 换文件后音量是否沿用看后端,静音时每次装完都再设一遍 - if (latestMuted) players[slot].setVolume(0f) + players[slot].setVolume(if (latestMuted) 0f else latestVolume) openMs = openStarted.elapsedNow().inWholeMilliseconds } catch (e: CancellationException) { throw e @@ -547,7 +551,7 @@ private fun ClipPager( if (!pointerSource.isTouchLike) runCatching { focusRequester.requestFocus() } } LaunchedEffect(pagerState) { snapshotFlow { latestSettled }.collect(session::moveTo) } - LaunchedEffect(muted) { players.forEach { it.setVolume(if (muted) 0f else 1f) } } + LaunchedEffect(muted, volume) { players.forEach { it.setVolume(if (muted) 0f else volume) } } // 手指还按着就翻走了,松手的事件落不到这一页 LaunchedEffect(settled) { stopBoost() } @@ -989,7 +993,7 @@ private fun ClipPager( Key.Spacebar, Key.MediaPlayPause -> if (!repeat) togglePause() Key.MediaPlay -> if (!repeat && paused) togglePause() Key.MediaPause, Key.MediaStop -> if (!repeat && !paused) togglePause() - Key.M -> if (!repeat) session.muted = !session.muted + Key.M -> if (!repeat) session.toggleMute() Key.A -> if (!repeat) onToggleAutoAdvance() else -> return@onPreviewKeyEvent false } diff --git a/ui/src/commonMain/kotlin/dev/piko/ui/screens/drive/PosterCard.kt b/ui/src/commonMain/kotlin/dev/piko/ui/screens/drive/PosterCard.kt index 7d9dc079..9c157401 100644 --- a/ui/src/commonMain/kotlin/dev/piko/ui/screens/drive/PosterCard.kt +++ b/ui/src/commonMain/kotlin/dev/piko/ui/screens/drive/PosterCard.kt @@ -37,6 +37,8 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.runtime.produceState +import dev.piko.ui.LocalPikoServices import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -118,6 +120,15 @@ internal fun PosterCard( /** 文件夹里直接放着归档条目,见 itemMarks。 */ folderHasVault: Boolean = false, ) { + val repository = LocalPikoServices.current.driveRepository + val durationSeconds by produceState(initialValue = null, file, repository) { + value = repository.previewDurationSeconds(file) + } + val durationLabel = durationSeconds?.takeIf { it > 0 }?.let { seconds -> + val minutes = (seconds % 3600 / 60).toString().padStart(2, '0') + val remainder = (seconds % 60).toString().padStart(2, '0') + if (seconds >= 3600) "${seconds / 3600}:$minutes:$remainder" else "$minutes:$remainder" + } val item = file.filePresentation(title, original, tags, code, resolution, folderHasVault, isEmptyFolder) val hover = fileItemHover() val coverShape = MaterialTheme.shapes.medium @@ -157,7 +168,7 @@ internal fun PosterCard( .wrapContentSize(Alignment.TopEnd), ) } - if (code != null || resolution != null) { + if (code != null || resolution != null || durationLabel != null) { // 左右两角放在同一行:窄卡片上长番号会碰到清晰度,同一行里番号先让出位置 Row( modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(6.dp), @@ -168,6 +179,7 @@ internal fun PosterCard( if (code != null) MediaTag(code, onMedia = true, emphasized = true) } if (resolution != null) MediaTag(resolution, onMedia = true) + if (durationLabel != null) MediaTag(durationLabel, onMedia = true) } } } @@ -186,6 +198,7 @@ internal fun PosterCard( Column(Modifier.weight(1f)) { FileItemTitle(item.name, style = MaterialTheme.typography.bodyMedium, maxLines = TITLE_LINES) FileItemOriginal(item.name) + dev.piko.ui.components.FileAddedTime(file) } } if (trailingShown && !isSelectionMode) { diff --git a/ui/src/commonMain/kotlin/dev/piko/ui/screens/player/PlayerControls.kt b/ui/src/commonMain/kotlin/dev/piko/ui/screens/player/PlayerControls.kt index ffdec2d1..2823724c 100644 --- a/ui/src/commonMain/kotlin/dev/piko/ui/screens/player/PlayerControls.kt +++ b/ui/src/commonMain/kotlin/dev/piko/ui/screens/player/PlayerControls.kt @@ -555,6 +555,8 @@ private val PlayContainerSize = DpSize(64.dp, 56.dp) @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun PlayerBottomBar( + isPlaying: Boolean, + onPlayPause: () -> Unit, isLandscape: Boolean, isFullscreen: Boolean, thumbOnHoverOnly: Boolean, @@ -624,6 +626,13 @@ internal fun PlayerBottomBar( verticalAlignment = Alignment.CenterVertically, ) { Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + PlayerIconButton( + icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + label = if (isPlaying) tr("暂停", "Pause", "一時停止", "일시 중지", "暫停") else tr("播放", "Play", "再生", "재생", "播放"), + onClick = onPlayPause, + shortcut = "Space", + ) + Spacer(Modifier.width(8.dp)) // 换集在时间前面,照多数播放器底栏的排法。没有上一集或下一集时禁用而不是隐藏,时间不会左右跳 if (showEpisodeSkip) { PlayerIconButton(icon = Icons.Filled.SkipPrevious, label = tr("上一集", "Previous episode", "前のエピソード", "이전 에피소드", "上一集"), onClick = onPrevious, enabled = hasPrevious, shortcut = "PageUp") diff --git a/ui/src/commonMain/kotlin/dev/piko/ui/screens/player/PlayerControlsOverlay.kt b/ui/src/commonMain/kotlin/dev/piko/ui/screens/player/PlayerControlsOverlay.kt index 6e469ebb..70726e0c 100644 --- a/ui/src/commonMain/kotlin/dev/piko/ui/screens/player/PlayerControlsOverlay.kt +++ b/ui/src/commonMain/kotlin/dev/piko/ui/screens/player/PlayerControlsOverlay.kt @@ -468,6 +468,8 @@ fun MobilePlayerControls( } Box(Modifier.fillMaxSize()) { PlayerBottomBar( + isPlaying = isPlaying, + onPlayPause = { ui.interacted(); onPlayPause() }, isLandscape = isLandscape, isFullscreen = isFullscreen, thumbOnHoverOnly = seekThumbOnHoverOnly, @@ -544,12 +546,12 @@ fun MobilePlayerControls( ) PlayerCenterControls( - visible = centerVisible, + visible = centerVisible && (isLoading || centerIndicator != null), isPlaying = isPlaying, isLoading = isLoading, indicator = centerIndicator, loadingSpeedText = loadingSpeedText, - modifier = Modifier.align(Alignment.Center), + modifier = Modifier.align(Alignment.BottomStart).padding(start = 16.dp, bottom = 120.dp), onPlayPause = { if (chromeVisible) { ui.interacted()