From 5fe98fa750fb7bd9381ab36c995eb2126d3937b7 Mon Sep 17 00:00:00 2001 From: The-R0 <2861295907@qq.com> Date: Wed, 11 Nov 2026 23:38:15 +0800 Subject: [PATCH] fix(player): always show settings button in fullscreen and improve danmaku input focus Always render PlayerSettingsButton in both the fullscreen top bar and bottom control bar, wrap the inline danmaku field and setting chips in a weighted BoxWithConstraints so right-side buttons are never pushed off-screen, and add a close action plus keyboard-hide focus release to ControlBarDanmakuField. --- .../dev/bilby/ui/live/LiveRoomScreen.kt | 17 ++- .../bilby/ui/player/ControlBarDanmakuField.kt | 87 ++++++++++--- .../dev/bilby/ui/player/PlayerControls.kt | 6 +- .../kotlin/dev/bilby/ui/player/PlayerShell.kt | 7 ++ .../kotlin/dev/bilby/ui/video/BilbyPlayer.kt | 115 ++++++++---------- 5 files changed, 145 insertions(+), 87 deletions(-) diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/live/LiveRoomScreen.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/live/LiveRoomScreen.kt index 1925e2f..4a6d3e1 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/ui/live/LiveRoomScreen.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/ui/live/LiveRoomScreen.kt @@ -134,7 +134,6 @@ import androidx.compose.runtime.CompositionLocalProvider import dev.bilby.ui.player.PlayerTooltip import dev.bilby.ui.video.LiveSettingsContent import dev.bilby.ui.video.PlayerSettingsButton -import dev.bilby.ui.video.DanmakuSettingsButton import dev.bilby.ui.player.ControlBarDanmaku import dev.bilby.ui.player.ControlBarDanmakuField import dev.bilby.ui.video.PlayerSettingsHost @@ -337,6 +336,10 @@ fun LiveRoomScreen( ) } } + PlayerSettingsButton { + settingsOnly = null + settingsOpen = true + } }, embeddedTopActions = { OnlineRankLabel(state.onlineRank) @@ -435,6 +438,7 @@ fun LiveRoomScreen( }, // 外壳与内容的排法同播放页,见 PlayerSettingsHost。 panel = { + if (settingsOpen) setMenuOpen(true) val close = { settingsOpen = false setMenuOpen(false) @@ -754,8 +758,8 @@ private fun LiveControlBar( error = danmakuField.error, onSend = danmakuField.onSend, onFocusChange = { focused -> + composing = focused onDanmakuFieldFocusChange(focused) - if (!focused) composing = false }, autoFocus = !roomy, focusRequestNonce = danmakuFocusNonce, @@ -788,13 +792,8 @@ private fun LiveControlBar( } } if (roomy || !composing) LiveAudioOnlyButton(onlyAudio, onOnlyAudioChange, isFullscreen) - if (wide) { - DanmakuButton(danmakuEnabled, onDanmakuEnabledChange, isFullscreen) - DanmakuSettingsButton { onOpenSettings(PlayerSettingsSection.Danmaku) } - } else { - if (isFullscreen) DanmakuButton(danmakuEnabled, onDanmakuEnabledChange, isFullscreen) - PlayerSettingsButton { onOpenSettings(null) } - } + if (wide || isFullscreen) DanmakuButton(danmakuEnabled, onDanmakuEnabledChange, isFullscreen) + PlayerSettingsButton { onOpenSettings(null) } FullscreenButton(isFullscreen, onFullscreenToggle) } } diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/player/ControlBarDanmakuField.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/player/ControlBarDanmakuField.kt index 5e64049..5d8518b 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/ui/player/ControlBarDanmakuField.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/ui/player/ControlBarDanmakuField.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -26,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -38,10 +40,12 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import dev.bilby.resources.Res +import dev.bilby.resources.action_close import dev.bilby.resources.action_send import dev.bilby.resources.danmaku_input_hint import dev.bilby.stringResource import dev.bilby.ui.components.LoadingSpinner +import dev.bilby.ui.components.ReleaseFocusWhenKeyboardHides import dev.bilby.ui.components.trackEditableFocus import dev.bilby.ui.theme.Dimens import dev.bilby.ui.theme.Spacing @@ -91,15 +95,41 @@ internal fun ControlBarDanmakuField( val hint = stringResource(Res.string.danmaku_input_hint) val canSend = draft.isNotBlank() && !sending val focusRequester = remember { FocusRequester() } - if (autoFocus) LaunchedEffect(Unit) { focusRequester.requestFocus() } + val keyboard = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + var focused by remember { mutableStateOf(false) } + var wasFocused by remember { mutableStateOf(false) } + var sendPending by remember { mutableStateOf(false) } + + if (autoFocus) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboard?.show() + } + } LaunchedEffect(focusRequestNonce) { - if (focusRequestNonce > 0) runCatching { focusRequester.requestFocus() } + if (focusRequestNonce > 0) { + runCatching { focusRequester.requestFocus() } + keyboard?.show() + } } - val focusManager = LocalFocusManager.current - var hadDraft by remember { mutableStateOf(false) } - LaunchedEffect(draft) { - if (draft.isEmpty() && hadDraft) focusManager.clearFocus() - hadDraft = draft.isNotEmpty() + ReleaseFocusWhenKeyboardHides(focused) + + val submit: () -> Unit = { + if (canSend) { + sendPending = true + onSend() + } + } + LaunchedEffect(sendPending, sending, draft, error) { + if (!sendPending) return@LaunchedEffect + if (!sending && draft.isEmpty()) { + sendPending = false + keyboard?.hide() + focusManager.clearFocus() + } else if (!sending && error != null) { + sendPending = false + } } // 外层撑到触摸下限,画出来的胶囊与旁边的 chip 同高。 @@ -129,12 +159,22 @@ internal fun ControlBarDanmakuField( textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { if (canSend) onSend() }), + keyboardActions = KeyboardActions(onSend = { submit() }), modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) .trackEditableFocus() - .onFocusChanged { onFocusChange(it.isFocused) } + // 刚进组合的那一帧焦点还没到,初始 false 不能当成"放掉焦点",否则点「发弹幕」 + // 展开的那一帧就会立刻把 composing 重置回 false。 + .onFocusChanged { state -> + focused = state.isFocused + if (state.isFocused) { + wasFocused = true + onFocusChange(true) + } else if (wasFocused) { + onFocusChange(false) + } + } .semantics { contentDescription = hint }, ) } @@ -148,16 +188,33 @@ internal fun ControlBarDanmakuField( ) } } - IconButton(onClick = onSend, enabled = canSend, modifier = Modifier.size(ControlChipHeight)) { - if (sending) { - LoadingSpinner() - } else { + if (autoFocus && draft.isEmpty() && !sending) { + IconButton( + onClick = { + keyboard?.hide() + focusManager.clearFocus() + onFocusChange(false) + }, + modifier = Modifier.size(ControlChipHeight), + ) { Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(Res.string.action_send), + Icons.Filled.Close, + contentDescription = stringResource(Res.string.action_close), modifier = Modifier.size(Dimens.IconInline), ) } + } else { + IconButton(onClick = submit, enabled = canSend, modifier = Modifier.size(ControlChipHeight)) { + if (sending) { + LoadingSpinner() + } else { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(Res.string.action_send), + modifier = Modifier.size(Dimens.IconInline), + ) + } + } } } } diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/player/PlayerControls.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/player/PlayerControls.kt index 70b1104..17c2a1a 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/ui/player/PlayerControls.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/ui/player/PlayerControls.kt @@ -39,6 +39,8 @@ import dev.bilby.formatDurationMillis import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -103,7 +105,9 @@ internal fun Modifier.playerControlBar(isFullscreen: Boolean): Modifier = listOf(Color.Transparent, FixedColors.PlayerControlScrim, ControlScrimBottom), ), ) - .windowInsetsPadding(WindowInsets.barsAndCutout) + .windowInsetsPadding( + if (isFullscreen) WindowInsets.barsAndCutout.union(WindowInsets.ime) else WindowInsets.barsAndCutout, + ) .padding( start = if (isFullscreen) Spacing.Comfortable else Spacing.Tight, end = if (isFullscreen) Spacing.Comfortable else Spacing.Tight, diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/player/PlayerShell.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/player/PlayerShell.kt index 3c020a6..ed97f2f 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/ui/player/PlayerShell.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/ui/player/PlayerShell.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -381,6 +382,7 @@ fun PlayerShell( // 手势的反馈只有画面正中那个浮层,而横屏时手指往往正压在它上面。触感补的是"这一下认了" // 这件事:长按真的进了加速、锁真的合上了、拖到取消区了。 val haptics = LocalHapticFeedback.current + val focusManager = LocalFocusManager.current val playback = LocalPlaybackHost.current val brightness = rememberWindowBrightness() val volume = rememberMediaVolume() @@ -779,6 +781,11 @@ fun PlayerShell( } detectTapGestures( onTap = { + if (menuOpen) { + focusManager.clearFocus() + keepAwake() + return@detectTapGestures + } if (pointerSource.isTouchLike) { controlsVisible = !controlsVisible } else { diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/video/BilbyPlayer.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/video/BilbyPlayer.kt index 939c231..14ff1c3 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/ui/video/BilbyPlayer.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/ui/video/BilbyPlayer.kt @@ -38,7 +38,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.zIndex import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -245,7 +244,13 @@ fun BilbyPlayer( speedController = speedController, showSpeedOverlay = showSpeedOverlay, gestures = gestures, - topBarActions = topBarActions, + topBarActions = { + topBarActions() + PlayerSettingsButton { + settingsOnly = null + settingsOpen = true + } + }, embeddedTopActions = embeddedTopActions, pip = pip, wheelAdjustsVolume = isFullscreen || twoPane, @@ -367,7 +372,6 @@ fun BilbyPlayer( currentAudio = currentAudio, isFullscreen = isFullscreen, largePlayer = isFullscreen || twoPane, - hasDanmakuSettings = danmakuEditor != null, danmakuField = controlBarDanmaku, danmakuFocusNonce = danmakuFocusNonce, // 写弹幕时控制条不自动收起,同设置面板开着时。 @@ -390,6 +394,7 @@ fun BilbyPlayer( ) }, panel = { + if (settingsOpen) setMenuOpen(true) val close = { settingsOpen = false setMenuOpen(false) @@ -452,8 +457,6 @@ private fun PlayerControlBar( isFullscreen: Boolean, /** 全屏,或双栏里那块占满整列高度的内嵌画面。只有这时才考虑宽排法。 */ largePlayer: Boolean, - /** 有没有弹幕设置可调。没有时宽排法不画那枚弹幕设置键。 */ - hasDanmakuSettings: Boolean, danmakuField: ControlBarDanmaku?, danmakuFocusNonce: Int = 0, onDanmakuFieldFocusChange: (Boolean) -> Unit, @@ -556,35 +559,17 @@ private fun PlayerControlBar( } else { // **两行负间距叠着放。** 两者都是 48dp 的触摸区,画出来的东西却只有十几 dp 高,两块 // 空 padding 摞在一起读起来像两组不相干的控件。叠 [ControlRowOverlap] 之后看着是一组, - // 两边的触摸区都还在 36dp 以上。进度条画在上层,叠掉的那一截归它。 + // 两边的触摸区都还在 36dp 以上。按钮行在后,叠掉的那一截归按钮,避免点设置/全屏偏上时误触进度条。 Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(-ControlRowOverlap), ) { - seekBar(Modifier.fillMaxWidth().zIndex(1f)) + seekBar(Modifier.fillMaxWidth()) // 弹幕输入框在读数与 chip 之间,同网页端。这一行放不下它和整排 chip 时,它收成一枚 // 「发弹幕」键,点开之后 chip 让位、输入框铺开,同窄屏胶囊写弹幕时视图切换退场。 val showField = danmakuField != null && (roomy || composing) Row(verticalAlignment = Alignment.CenterVertically) { - if (composing && danmakuField != null && !wide) { - // 不够宽时写弹幕:输入框顶掉读数铺整行,同上面窄排法。 - ControlBarDanmakuField( - draft = danmakuField.draft, - onDraftChange = danmakuField.onDraftChange, - maxLength = danmakuField.maxLength, - sending = danmakuField.sending, - error = danmakuField.error, - onSend = danmakuField.onSend, - onFocusChange = { focused -> - onDanmakuFieldFocusChange(focused) - danmakuField.onComposingChange(focused) - if (!focused) composing = false - }, - autoFocus = true, - focusRequestNonce = danmakuFocusNonce, - modifier = Modifier.weight(1f).padding(horizontal = Spacing.Cozy), - ) - } else { + if (!composing || roomy) { Row( verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(start = Spacing.Tight, end = Spacing.Tight), @@ -593,38 +578,48 @@ private fun PlayerControlBar( TimeLabel(duration, large = true, secondary = true, prefix = " / ") } PlayerVolumeControl() - if (showField) { - ControlBarDanmakuField( - draft = danmakuField.draft, - onDraftChange = danmakuField.onDraftChange, - maxLength = danmakuField.maxLength, - sending = danmakuField.sending, - error = danmakuField.error, - onSend = danmakuField.onSend, - onFocusChange = { focused -> - onDanmakuFieldFocusChange(focused) - danmakuField.onComposingChange(focused) - if (!focused) composing = false - }, - autoFocus = !roomy, - focusRequestNonce = danmakuFocusNonce, - modifier = Modifier.weight(1f).padding(horizontal = Spacing.Cozy), - ) - } else { - Spacer(Modifier.weight(1f)) - } } - if (wide && (roomy || !composing)) { - SettingChips( - speed = speed, - // 只有一档时没什么可换,chip 不画(面板里那一段同样不画)。 - qualityLabel = qualities.takeIf { it.size > 1 } - ?.firstOrNull { it.quality == currentQuality }?.label, - audioLabel = currentAudio.takeIf { audioOptions.size > 1 }?.let { audioQualityLabel(it) }, - subtitleLabel = subtitleTracks.firstOrNull { it.lan == currentSubtitleLan }?.displayName, - hasSubtitles = subtitleTracks.isNotEmpty(), - onOpenSection = onOpenSettings, - ) + // 中间段(弹幕输入框 + 档位 chip)装进 weight(1f) 容器,保证右侧的弹幕开关、 + // 设置按钮、全屏按钮由外层 Row 优先测量,任何字号/屏幕宽度下都不会被挤出屏幕。 + BoxWithConstraints(modifier = Modifier.weight(1f)) { + val showChips = wide && !composing && maxWidth >= if (showField) 420.dp else 240.dp + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + if (showField) { + ControlBarDanmakuField( + draft = danmakuField.draft, + onDraftChange = danmakuField.onDraftChange, + maxLength = danmakuField.maxLength, + sending = danmakuField.sending, + error = danmakuField.error, + onSend = danmakuField.onSend, + onFocusChange = { focused -> + composing = focused + onDanmakuFieldFocusChange(focused) + danmakuField.onComposingChange(focused) + }, + autoFocus = !roomy, + focusRequestNonce = danmakuFocusNonce, + modifier = Modifier.weight(1f).padding(horizontal = Spacing.Cozy), + ) + } else { + Spacer(Modifier.weight(1f)) + } + if (showChips) { + SettingChips( + speed = speed, + // 只有一档时没什么可换,chip 不画(面板里那一段同样不画)。 + qualityLabel = qualities.takeIf { it.size > 1 } + ?.firstOrNull { it.quality == currentQuality }?.label, + audioLabel = currentAudio.takeIf { audioOptions.size > 1 }?.let { audioQualityLabel(it) }, + subtitleLabel = subtitleTracks.firstOrNull { it.lan == currentSubtitleLan }?.displayName, + hasSubtitles = subtitleTracks.isNotEmpty(), + onOpenSection = onOpenSettings, + ) + } + } } if (danmakuField != null && !showField) { val label = stringResource(Res.string.danmaku_send) @@ -633,11 +628,7 @@ private fun PlayerControlBar( } } DanmakuButton(danmakuEnabled, onDanmakuEnabledChange, isFullscreen) - if (wide) { - if (hasDanmakuSettings) DanmakuSettingsButton { onOpenSettings(PlayerSettingsSection.Danmaku) } - } else { - PlayerSettingsButton { onOpenSettings(null) } - } + PlayerSettingsButton { onOpenSettings(null) } FullscreenButton(isFullscreen, onFullscreenToggle) } }