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) } }