adapt landscape direction

This commit is contained in:
2026-09-26 20:38:09 +08:00
parent 027171cf59
commit cf7a5ed2ed
15 changed files with 1264 additions and 148 deletions
@@ -18,7 +18,9 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -42,6 +44,13 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -78,6 +87,7 @@ import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme
import com.zhushenwudi.lovelivemusicplayer.ui.components.AddToMenuDialog
import com.zhushenwudi.lovelivemusicplayer.ui.components.AppDrawer
import com.zhushenwudi.lovelivemusicplayer.ui.components.BatchActionBar
import com.zhushenwudi.lovelivemusicplayer.ui.components.LandscapeLibraryHeader
import com.zhushenwudi.lovelivemusicplayer.ui.components.LibrarySelectMode
import com.zhushenwudi.lovelivemusicplayer.ui.components.LlmpBottomBar
import com.zhushenwudi.lovelivemusicplayer.ui.components.LoadingOverlay
@@ -99,9 +109,11 @@ import com.zhushenwudi.lovelivemusicplayer.ui.scroll.StatusBarScrollToTopTap
import com.zhushenwudi.lovelivemusicplayer.ui.scroll.rememberStatusBarScrollToTopAction
import com.zhushenwudi.lovelivemusicplayer.ui.theme.HomeListChrome
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalHomeListBottomInset
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
import com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideUnscaledDensity
import com.zhushenwudi.lovelivemusicplayer.ui.theme.asUnscaledInScaledContext
import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledNavigationBarsBottom
import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledSafeDrawingHorizontal
import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledStatusBarsTop
import com.zhushenwudi.lovelivemusicplayer.ui.weslide.WeSlide
import kotlinx.coroutines.IO
@@ -156,8 +168,10 @@ fun MainShell(
// 选择模式 > 0 时锁定歌库/我的标签与 WeSlide 手势
val batchLock = selectMode == LibrarySelectMode.Batch
val detailOpen = libraryDetail != null
LaunchedEffect(weSlide.isOpened, detailOpen) {
if (weSlide.isOpened || detailOpen) {
val isLandscape = LocalIsLandscape.current
LaunchedEffect(weSlide.isOpened, detailOpen, isLandscape) {
// 横屏详情仍保留底栏(三个入口 + 迷你条);全屏播放器展开时收起。
if (weSlide.isOpened || (detailOpen && !isLandscape)) {
weSlideFooter.hide()
} else {
weSlideFooter.show()
@@ -500,6 +514,14 @@ fun MainShell(
}
val shellBg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary
val focusManager = LocalFocusManager.current
val searchCoords = remember { mutableStateOf<LayoutCoordinates?>(null) }
var searchShellCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val landscapeSearchVisible = selectMode != LibrarySelectMode.Batch &&
libraryTab != LibraryTab.ALBUMS &&
libraryTab != LibraryTab.ARTISTS &&
libraryTab != LibraryTab.MENUS
val (safeStart, safeEnd) = unscaledSafeDrawingHorizontal()
val footerNavInset = unscaledNavigationBarsBottom()
val footerHeightUnscaled = when {
selectMode == LibrarySelectMode.Batch && batchSelectable -> 102.dp + footerNavInset
@@ -507,7 +529,12 @@ fun MainShell(
}
val miniPlayerHeight = HomeListChrome.MiniPlayerHeight
val footerHeight = footerHeightUnscaled.asUnscaledInScaledContext()
val panelMinSize = miniPlayerHeight + HomeListChrome.MiniPlayerFooterGap + footerHeight
// 横屏迷你条在底栏右侧,收起时面板只露出与底栏等高的一条,由底栏盖住。
val panelMinSize = if (isLandscape) {
footerHeight
} else {
miniPlayerHeight + HomeListChrome.MiniPlayerFooterGap + footerHeight
}
val statusBarTop = unscaledStatusBarsTop().asUnscaledInScaledContext()
fun importData(force: Boolean) {
@@ -584,8 +611,13 @@ fun MainShell(
drawerContent = {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
BoxWithConstraints(Modifier.fillMaxHeight()) {
// 与原项目 endDrawer:min(0.35 * 屏高, 屏宽),不用 Material 默认 240–360dp。
val drawerWidth = min(maxHeight * 0.35f, maxWidth)
// 竖屏与原项目 endDrawer:min(0.35 * 屏高, 屏宽)。
// 横屏屏高变矮,改用约 300dp,且不超过屏宽的 45%。
val drawerWidth = if (isLandscape) {
min(300.dp, maxWidth * 0.45f)
} else {
min(maxHeight * 0.35f, maxWidth)
}
Surface(
color = if (dark) ColorMs.NightPrimary else ColorMs.White,
modifier = Modifier
@@ -618,9 +650,42 @@ fun MainShell(
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Ltr,
LocalMiniPlayerHaze provides if (homeHazeEnabled) miniPlayerHaze else null,
LocalHomeListBottomInset provides HomeListChrome.ListBottomInset,
LocalHomeListBottomInset provides if (isLandscape) {
16.dp
} else {
HomeListChrome.ListBottomInset
},
) {
WeSlide(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { shellCoords ->
searchShellCoords = shellCoords
}
.pointerInput(focusManager) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
val down = event.changes.firstOrNull {
it.changedToDownIgnoreConsumed()
} ?: continue
val root = searchShellCoords
val field = searchCoords.value
val inside = root != null &&
field != null &&
root.isAttached &&
field.isAttached &&
run {
val local = field.localPositionOf(root, down.position)
local.x >= 0f &&
local.y >= 0f &&
local.x <= field.size.width &&
local.y <= field.size.height
}
if (!inside) focusManager.clearFocus()
}
}
},
controller = weSlide,
footerController = weSlideFooter,
panelMinSize = panelMinSize,
@@ -664,8 +729,49 @@ fun MainShell(
Modifier
.fillMaxSize()
.background(shellBg)
.padding(top = statusBarTop),
.padding(top = statusBarTop)
.then(
if (isLandscape) {
Modifier.padding(
start = safeStart + 20.dp,
end = safeEnd + 20.dp,
)
} else {
Modifier
},
),
) {
if (isLandscape) {
LandscapeLibraryHeader(
section = section,
onSection = { next ->
topTabIndex =
if (next == TopSection.LIBRARY) 0 else 1
},
interactionEnabled = !batchLock && !detailOpen,
query = searchDraft,
onQueryChange = { next ->
searchDraft = next
searchQuery = next
if (selectMode != LibrarySelectMode.Batch) {
selectMode = if (next.isBlank()) {
LibrarySelectMode.Normal
} else {
LibrarySelectMode.Search
}
}
},
group = currentGroup,
onOpenDrawer = if (batchLock || detailOpen) {
null
} else {
{ scope.launch { drawerState.open() } }
},
shellBg = shellBg,
showSearch = landscapeSearchVisible,
onSearchPlaced = { searchCoords.value = it },
)
} else {
Row(
Modifier
.fillMaxWidth()
@@ -704,6 +810,7 @@ fun MainShell(
hasShadow = true,
)
}
}
if (libraryTab != LibraryTab.ARTISTS &&
libraryTab != LibraryTab.MENUS
@@ -711,7 +818,15 @@ fun MainShell(
SongLibraryTopBar(
countLabel = countLabel,
libraryTab = libraryTab,
selectMode = selectMode,
selectMode = if (
isLandscape &&
selectMode == LibrarySelectMode.Search
) {
LibrarySelectMode.Normal
} else {
selectMode
},
showSearch = !isLandscape,
sortAsc = currentSortAsc,
searchQuery = searchDraft,
checkedCount = checkedIds.size,
@@ -797,65 +912,17 @@ fun MainShell(
}
// 进入:右滑;退出:页面渐隐 + 封面 Hero(零位移保持转场时长)。
AnimatedVisibility(
// 抽到独立函数,避免外层 Row 把 AnimatedVisibility 解析成行内重载。
LibraryDetailOverlay(
visible = detailOpen,
enter = slideInHorizontally(
animationSpec = tween(
LlmpTransitionMs,
easing = EaseOutQuad,
),
initialOffsetX = { it },
) + fadeIn(
animationSpec = tween(
LlmpTransitionMs,
easing = EaseOutQuad,
),
),
exit = slideOutHorizontally(
animationSpec = tween(
LlmpTransitionMs,
easing = EaseOutQuad,
),
targetOffsetX = { 0 },
),
modifier = Modifier.fillMaxSize(),
) {
CompositionLocalProvider(
LocalNavAnimatedVisibilityScope provides this,
) {
when (val detail = retainedDetail) {
is LibraryDetail.Album -> AlbumDetailScreen(
albumId = detail.albumId,
onBack = ::closeLibraryDetail,
detail = retainedDetail,
onClose = ::closeLibraryDetail,
onOpenPlayer = {
closeLibraryDetail()
weSlide.show()
},
)
is LibraryDetail.Artist -> ArtistDetailScreen(
artistId = detail.artistId,
onBack = ::closeLibraryDetail,
onOpenPlayer = {
closeLibraryDetail()
weSlide.show()
},
)
is LibraryDetail.Menu -> MenuDetailScreen(
menuId = detail.menuId,
onBack = ::closeLibraryDetail,
onOpenPlayer = {
closeLibraryDetail()
weSlide.show()
},
)
null -> Unit
}
}
}
// 点击状态栏将当前曲库列表滚回顶部。
if (!detailOpen) {
LibraryScrollToTopHandler(libraryTab)
@@ -870,11 +937,15 @@ fun MainShell(
}
}
},
panelHeader = {
panelHeader = if (isLandscape) {
null
} else {
{
MiniPlayerBar(
onOpenPlayer = { weSlide.show() },
interactionEnabled = !batchLock,
)
}
},
panel = {
FullPlayerScreen(
@@ -884,8 +955,8 @@ fun MainShell(
},
footer = {
ProvideUnscaledDensity {
if (detailOpen) {
// 详情页不画底栏,只保留槽位高度动画给迷你条下移。
if (detailOpen && !isLandscape) {
// 竖屏详情不画底栏,只保留槽位高度动画给迷你条下移。
} else if (selectMode == LibrarySelectMode.Batch && batchSelectable) {
BatchActionBar(
showUnlove = libraryTab == LibraryTab.LOVE,
@@ -897,6 +968,54 @@ fun MainShell(
if (selectedMusic().isNotEmpty()) showUnloveConfirm = true
},
)
} else if (isLandscape) {
val barBg = if (dark) {
ColorMs.BottomBarDark
} else {
ColorMs.BottomBarLight
}
val barDensity = LocalDensity.current
val barDirection = LocalLayoutDirection.current
val barSafeStart = with(barDensity) {
WindowInsets.safeDrawing.getLeft(this, barDirection).toDp()
}
val barSafeEnd = with(barDensity) {
WindowInsets.safeDrawing.getRight(this, barDirection).toDp()
}
Row(
Modifier
.fillMaxSize()
.background(barBg)
.padding(
start = barSafeStart + 20.dp,
end = barSafeEnd + 20.dp,
),
) {
LlmpBottomBar(
modifier = Modifier.weight(1.15f).fillMaxHeight(),
section = section,
selectedIndex = bottomIndex,
horizontalLabels = true,
onSelect = { index ->
bottomIndex = index
},
onSectionChange = { next ->
val top = if (next == TopSection.LIBRARY) 0 else 1
if (topTabIndex != top) {
topTabIndex = top
}
},
)
MiniPlayerBar(
onOpenPlayer = {
if (detailOpen) closeLibraryDetail()
weSlide.show()
},
interactionEnabled = !batchLock,
embedded = true,
modifier = Modifier.weight(0.85f).fillMaxHeight(),
)
}
} else {
LlmpBottomBar(
modifier = Modifier.fillMaxSize(),
@@ -983,3 +1102,49 @@ fun MainShell(
)
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun LibraryDetailOverlay(
visible: Boolean,
detail: LibraryDetail?,
onClose: () -> Unit,
onOpenPlayer: () -> Unit,
) {
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(
animationSpec = tween(LlmpTransitionMs, easing = EaseOutQuad),
initialOffsetX = { it },
) + fadeIn(animationSpec = tween(LlmpTransitionMs, easing = EaseOutQuad)),
exit = slideOutHorizontally(
animationSpec = tween(LlmpTransitionMs, easing = EaseOutQuad),
targetOffsetX = { 0 },
),
modifier = Modifier.fillMaxSize(),
) {
CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this) {
when (detail) {
is LibraryDetail.Album -> AlbumDetailScreen(
albumId = detail.albumId,
onBack = onClose,
onOpenPlayer = onOpenPlayer,
)
is LibraryDetail.Artist -> ArtistDetailScreen(
artistId = detail.artistId,
onBack = onClose,
onOpenPlayer = onOpenPlayer,
)
is LibraryDetail.Menu -> MenuDetailScreen(
menuId = detail.menuId,
onBack = onClose,
onOpenPlayer = onOpenPlayer,
)
null -> Unit
}
}
}
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Text
@@ -48,6 +49,8 @@ fun LlmpBottomBar(
onSelect: (Int) -> Unit,
onSectionChange: (TopSection) -> Unit,
modifier: Modifier = Modifier,
/** 横屏底栏宽度够,图标和文字左右排列。 */
horizontalLabels: Boolean = false,
) {
val dark = LocalDarkTheme.current
val tabStripHeight = HomeListChrome.BottomBarContentHeight
@@ -99,10 +102,13 @@ fun LlmpBottomBar(
) {
tabs.forEachIndexed { index, tab ->
val selected = pageSection == section && index == selectedIndex
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
val tint = if (selected) ColorMs.PinkF940A7 else ColorMs.UnselectedIcon
val labelColor = if (selected) {
ColorMs.BottomSelectedText
} else {
ColorMs.TabUnselected.copy(alpha = 0.5f)
}
val itemModifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(
@@ -113,11 +119,35 @@ fun LlmpBottomBar(
onSectionChange(pageSection)
}
onSelect(index)
},
}
if (horizontalLabels) {
Row(
modifier = itemModifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
TintIcon(
asset = tab.icon,
tint = if (selected) ColorMs.PinkF940A7 else ColorMs.UnselectedIcon,
tint = tint,
size = 18.dp,
contentDescription = tr(tab.titleKey),
)
Spacer(Modifier.width(6.dp))
Text(
text = tr(tab.titleKey),
fontSize = 13.sp,
color = labelColor,
)
}
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = itemModifier,
) {
TintIcon(
asset = tab.icon,
tint = tint,
size = 18.dp,
contentDescription = tr(tab.titleKey),
)
@@ -125,11 +155,7 @@ fun LlmpBottomBar(
Text(
text = tr(tab.titleKey),
fontSize = 10.sp,
color = if (selected) {
ColorMs.BottomSelectedText
} else {
ColorMs.TabUnselected.copy(alpha = 0.5f)
},
color = labelColor,
)
}
}
@@ -137,6 +163,7 @@ fun LlmpBottomBar(
}
}
}
}
private val BottomTab.icon: SvgAsset
get() = when (this) {
@@ -8,10 +8,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -21,20 +23,25 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledSafeDrawingHorizontal
import com.zhushenwudi.lovelivemusicplayer.AppContainer
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
@@ -49,6 +56,9 @@ import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons
/** 对齐 Flutter 末项 `95.h`:只挡住迷你播放器,不再叠加已收起的底栏高度。 */
private val DetailsListBottomInset = 95.dp
/** 详情封面边长。竖屏 240;横屏按左侧栏剩余高度缩小。 */
val LocalDetailCoverSize = staticCompositionLocalOf { 240.dp }
/**
* 详情页:顶栏、封面、粘性播放栏、歌曲列表。
*/
@@ -145,6 +155,38 @@ fun DetailsPage(
.background(scaffoldBg)
.graphicsLayer { alpha = bodyAlpha },
)
if (LocalIsLandscape.current) {
LandscapeDetailColumns(
title = title,
songs = songs,
onBack = onBack,
bodyFade = bodyFade,
scaffoldBg = scaffoldBg,
selectMode = selectMode,
checkedIds = checkedIds,
playingId = playingId,
onPlayAll = { playQueue() },
onOpenSelect = { selectMode = true },
onSelectAll = { checked ->
checkedIds = if (checked) {
songs.mapNotNull { it.musicId }.toSet()
} else {
emptySet()
}
},
onCancelSelect = ::closeSelect,
onPlay = { playQueue(it) },
onPlayNext = { item ->
addMusicPlayNext(item)
PlatformUi.showToast(I18n.t("add_success"))
},
onMore = { moreMusic = it },
onCheckedChange = { id, checked ->
checkedIds = if (checked) checkedIds + id else checkedIds - id
},
cover = cover,
)
} else {
Column(Modifier.fillMaxSize()) {
AppHeader(title = title, onBack = onBack, modifier = bodyFade)
Spacer(Modifier.height(8.dp))
@@ -213,6 +255,7 @@ fun DetailsPage(
}
}
}
}
if (selectMode) {
BatchActionBar(
@@ -280,6 +323,99 @@ fun DetailsPage(
}
}
@Composable
private fun LandscapeDetailColumns(
title: String,
songs: List<Music>,
onBack: () -> Unit,
bodyFade: Modifier,
scaffoldBg: Color,
selectMode: Boolean,
checkedIds: Set<String>,
playingId: String?,
onPlayAll: () -> Unit,
onOpenSelect: () -> Unit,
onSelectAll: (Boolean) -> Unit,
onCancelSelect: () -> Unit,
onPlay: (Music) -> Unit,
onPlayNext: (Music) -> Unit,
onMore: (Music) -> Unit,
onCheckedChange: (String, Boolean) -> Unit,
cover: @Composable () -> Unit,
) {
val (safeStart, safeEnd) = unscaledSafeDrawingHorizontal()
Column(
Modifier
.fillMaxSize()
.padding(start = safeStart + 16.dp, end = safeEnd + 16.dp),
) {
AppHeader(title = title, onBack = onBack, modifier = bodyFade)
Row(Modifier.weight(1f).fillMaxWidth()) {
Column(
Modifier
.width(240.dp)
.fillMaxHeight(),
) {
BoxWithConstraints(
Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
val available = (maxHeight - 80.dp).coerceAtLeast(64.dp)
val coverSize = maxWidth.coerceAtMost(available).coerceAtMost(240.dp)
CompositionLocalProvider(LocalDetailCoverSize provides coverSize) {
cover()
}
}
DetailsListTop(
itemsLength = songs.size,
selectMode = selectMode,
selectAll = songs.isNotEmpty() &&
songs.mapNotNull { it.musicId }.all { it in checkedIds },
checkedCount = checkedIds.size,
onPlayTap = onPlayAll,
onFunctionTap = onOpenSelect,
onSelectAllTap = onSelectAll,
onCancelTap = onCancelSelect,
pinnedBg = scaffoldBg,
modifier = bodyFade,
)
}
LazyColumn(
modifier = Modifier.weight(1f).fillMaxHeight(),
contentPadding = PaddingValues(
top = 8.dp,
bottom = if (selectMode) 120.dp else 12.dp,
),
) {
itemsIndexed(
songs,
key = { _, m -> m.musicId ?: m.hashCode().toString() },
) { index, item ->
val id = item.musicId.orEmpty()
SongListItem(
music = item,
isPlaying = id.isNotEmpty() && id == playingId,
onClick = { onPlay(item) },
onPlayNext = { onPlayNext(item) },
onMore = { onMore(item) },
selectMode = selectMode,
checked = id.isNotEmpty() && id in checkedIds,
onCheckedChange = { checked ->
if (id.isNotEmpty()) onCheckedChange(id, checked)
},
modifier = bodyFade.padding(
bottom = if (index == songs.lastIndex) 0.dp else 10.dp,
),
)
}
}
}
}
}
/** 详情顶栏 — 播放全部/选择栏,粘性定位。 */
@Composable
fun DetailsListTop(
@@ -52,6 +52,7 @@ import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
/**
* 双按钮对话框 — 对齐 Flutter [TwoButtonDialog]。
@@ -360,9 +361,15 @@ private fun LlmpDialogShell(
contentAlignment = Alignment.Center,
) {
// 记住首次布局宽度,避免键盘弹出时 adjustResize 缩小 maxHeight 导致弹窗变窄。
val dialogWidth = remember {
// 横屏不用屏高系数,否则弹窗会被压成一条。
val isLandscape = LocalIsLandscape.current
val dialogWidth = remember(isLandscape) {
if (isLandscape) {
min(maxWidth * 0.72f, 420.dp)
} else {
min(maxHeight * 0.4f, maxWidth * 0.8f)
}
}
Column(
modifier = Modifier
.width(dialogWidth)
@@ -8,29 +8,37 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.navigation.LibraryTab
import com.zhushenwudi.lovelivemusicplayer.navigation.TopSection
@@ -39,6 +47,7 @@ import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme
import com.zhushenwudi.lovelivemusicplayer.theme.katongFontFamily
import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons
import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset
import com.zhushenwudi.lovelivemusicplayer.ui.icons.TintIcon
@Composable
fun TopLibraryTabs(
@@ -95,6 +104,132 @@ fun TopLibraryTabs(
}
}
/**
* 横屏顶栏:左侧歌库/我的,右侧搜索条和抽屉按钮。
*/
@Composable
fun LandscapeLibraryHeader(
section: TopSection,
onSection: (TopSection) -> Unit,
interactionEnabled: Boolean,
query: String,
onQueryChange: (String) -> Unit,
group: GroupKey,
onOpenDrawer: (() -> Unit)?,
shellBg: Color,
showSearch: Boolean,
onSearchPlaced: (LayoutCoordinates?) -> Unit = {},
modifier: Modifier = Modifier,
) {
val dark = LocalDarkTheme.current
val hint = if (dark) ColorMs.GrayDFD else ColorMs.Gray999
val fieldBg = if (dark) {
ColorMs.White.copy(alpha = 0.08f)
} else {
ColorMs.Text333.copy(alpha = 0.06f)
}
val textColor = if (dark) ColorMs.White else ColorMs.Text333
DisposableEffect(showSearch) {
if (!showSearch) onSearchPlaced(null)
onDispose { onSearchPlaced(null) }
}
Row(
modifier
.fillMaxWidth()
.height(52.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TopLibraryTabs(
selected = section,
onSelect = onSection,
interactionEnabled = interactionEnabled,
)
Box(
Modifier.weight(1f),
contentAlignment = Alignment.CenterEnd,
) {
if (showSearch) {
Row(
Modifier
.padding(start = 16.dp)
.widthIn(max = 360.dp)
.fillMaxWidth()
.height(36.dp)
.onGloballyPositioned { onSearchPlaced(it) }
.clip(RoundedCornerShape(18.dp))
.background(fieldBg)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TintIcon(
asset = LlmpIcons.Search,
tint = hint,
size = 16.dp,
contentDescription = tr("search"),
)
Spacer(Modifier.width(8.dp))
Box(
Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.CenterStart,
) {
if (query.isEmpty()) {
Text(
tr("input_song_keyword"),
color = hint,
fontSize = 14.sp,
maxLines = 1,
)
}
BasicTextField(
value = query,
onValueChange = onQueryChange,
enabled = interactionEnabled,
singleLine = true,
textStyle = TextStyle(
color = textColor,
fontSize = 14.sp,
lineHeight = 14.sp,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both,
),
),
cursorBrush = SolidColor(ColorMs.PinkF940A7),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onQueryChange(query) }),
modifier = Modifier.fillMaxWidth(),
)
}
if (query.isNotEmpty() && interactionEnabled) {
Text(
"✕",
color = hint,
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = { onQueryChange("") },
)
.padding(4.dp),
)
}
}
}
}
Spacer(Modifier.width(12.dp))
NeumorphicGroupLogoButton(
group = group,
onClick = onOpenDrawer,
size = 36.dp,
background = if (group.isDefaultGroupLogo()) Color(0x4DFFAE00) else shellBg,
contentPadding = if (group.isDefaultGroupLogo()) 3.dp else 0.dp,
hasShadow = true,
)
}
}
private val FilterIcon = SvgAsset("drawable/main_ic_function.svg")
/** 选择模式:0 正常,1 批量,2 搜索。 */
@@ -119,6 +254,7 @@ fun SongLibraryTopBar(
onSelectAll: (Boolean) -> Unit,
onBatchFinish: () -> Unit,
modifier: Modifier = Modifier,
showSearch: Boolean = true,
) {
when (selectMode) {
LibrarySelectMode.Batch -> BatchTopBar(
@@ -145,6 +281,7 @@ fun SongLibraryTopBar(
onSearchOpen = onSearchOpen,
onSort = onSort,
onBatchOpen = onBatchOpen,
showSearch = showSearch,
modifier = modifier,
)
}
@@ -159,6 +296,7 @@ private fun NormalTopBar(
onSearchOpen: () -> Unit,
onSort: () -> Unit,
onBatchOpen: () -> Unit,
showSearch: Boolean = true,
modifier: Modifier = Modifier,
) {
val dark = LocalDarkTheme.current
@@ -180,7 +318,7 @@ private fun NormalTopBar(
maxLines = 1,
modifier = Modifier.weight(1f),
)
if (!isAlbum) {
if (!isAlbum && showSearch) {
NeumorphicIconButton(
icon = LlmpIcons.Search,
onClick = onSearchOpen,
@@ -68,6 +68,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
@@ -93,6 +94,8 @@ import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicSurface
import com.zhushenwudi.lovelivemusicplayer.ui.components.PlayerMusicCover
import com.zhushenwudi.lovelivemusicplayer.ui.components.rememberMusicCoverUri
import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledSafeDrawingHorizontal
import kotlin.math.abs
import kotlin.math.roundToLong
import kotlin.time.Duration.Companion.seconds
@@ -164,9 +167,10 @@ fun FullPlayerScreen(
if (panelOpen) content = PlayerContent.Cover
}
// 歌词列表:禁用 WeSlide 嵌套/面板拖拽,避免滚动被抢占。
// 歌词区与进度条之间的顶栏仍通过 onChromeVerticalDrag 驱动关闭。
LaunchedEffect(content) {
AppContainer.weSlide.dragEnabled = content == PlayerContent.Cover
// 横屏右侧始终是歌词,拖拽只留在左侧封面区。
val landscape = LocalIsLandscape.current
LaunchedEffect(content, landscape) {
AppContainer.weSlide.dragEnabled = !landscape && content == PlayerContent.Cover
}
DisposableEffect(Unit) {
onDispose { AppContainer.weSlide.dragEnabled = true }
@@ -190,6 +194,42 @@ fun FullPlayerScreen(
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().background(bg))
if (landscape) {
LandscapePlayerBody(
current = current,
isPlaying = isPlaying,
loopMode = loopMode,
lyric = lyric,
lrcType = lrcType,
isLove = isLove,
dark = dark,
iconTint = iconTint,
controlBg = controlBg,
thumbColor = thumbColor,
activeTrack = activeTrack,
inactiveTrack = inactiveTrack,
timeColor = timeColor,
lyricPlayingColor = lyricPlayingColor,
lyricOtherColor = lyricOtherColor,
listState = listState,
onBack = onBack,
onMore = { if (current != null) showMore = true },
onToggleLove = {
current?.musicId?.let { id ->
isLove = AppContainer.toggleLove(id)
}
},
onAddSong = {
if (current?.musicId != null) showAddToMenu = true
},
onToggleTranslate = {
AppContainer.lyricService.toggleType()
lrcType = AppContainer.lyricService.lrcType
lyric = AppContainer.applyLyricDisplayType()
},
onShowPlaylist = { showPlaylist = true },
)
} else {
Column(
Modifier
.fillMaxSize()
@@ -361,7 +401,7 @@ fun FullPlayerScreen(
background = controlBg,
)
NeumorphicIconButton(
icon = if (isPlaying) LlmpIcons.Pause else LlmpIcons.Play,
icon = if (isPlaying && current != null) LlmpIcons.Pause else LlmpIcons.Play,
onClick = { AppContainer.player.toggle() },
size = 80.dp,
iconSize = 26.dp,
@@ -394,6 +434,7 @@ fun FullPlayerScreen(
Spacer(Modifier.height(28.dp))
}
}
}
if (showAddToMenu) {
AddToMenuDialog(
@@ -426,6 +467,269 @@ fun FullPlayerScreen(
}
}
@Composable
private fun LandscapePlayerBody(
current: Music?,
isPlaying: Boolean,
loopMode: LoopMode,
lyric: LoadedLyric,
lrcType: Int,
isLove: Boolean,
dark: Boolean,
iconTint: Color,
controlBg: Color,
thumbColor: Color,
activeTrack: Color,
inactiveTrack: Color,
timeColor: Color,
lyricPlayingColor: Color,
lyricOtherColor: Color,
listState: LazyListState,
onBack: () -> Unit,
onMore: () -> Unit,
onToggleLove: () -> Unit,
onAddSong: () -> Unit,
onToggleTranslate: () -> Unit,
onShowPlaylist: () -> Unit,
) {
val (safeStart, safeEnd) = unscaledSafeDrawingHorizontal()
Row(
Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.padding(
start = safeStart + 28.dp,
end = safeEnd + 28.dp,
top = 12.dp,
bottom = 16.dp,
),
) {
Column(
Modifier
.weight(0.42f)
.fillMaxHeight()
.playerCollapseDrag()
.padding(end = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
Modifier.fillMaxWidth().height(40.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NeumorphicIconButton(
imageVector = Icons.Filled.KeyboardArrowDown,
onClick = onBack,
size = 32.dp,
iconSize = 20.dp,
radius = 6.dp,
contentPadding = 5.dp,
iconColor = iconTint,
background = Color.Transparent,
contentDescription = tr("collapse"),
)
}
Box(
Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val side = maxWidth.coerceAtMost(maxHeight).coerceAtLeast(1.dp)
PlayerCoverArt(music = current, size = side)
}
}
Text(
text = current?.musicName ?: tr("not_playing"),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
style = TextStyle(
color = if (dark) ColorMs.White else ColorMs.Text333,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
)
val artist = current?.artist.orEmpty()
if (artist.isNotBlank()) {
Text(
text = artist,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
style = TextStyle(
color = if (dark) ColorMs.GrayDFD else ColorMs.Gray999,
fontSize = 12.sp,
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
)
}
PlayerFuncButtons(
content = PlayerContent.Cover,
isLove = isLove,
lrcType = lrcType,
dark = dark,
iconTint = iconTint,
onToggleLove = onToggleLove,
onAddSong = onAddSong,
onToggleTranslate = onToggleTranslate,
)
PlayerSeekBar(
thumbColor = thumbColor,
activeTrack = activeTrack,
inactiveTrack = inactiveTrack,
timeColor = timeColor,
onSeek = { AppContainer.player.seekTo(it) },
)
Row(
Modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
NeumorphicIconButton(
icon = when (loopMode) {
LoopMode.LIST -> LlmpIcons.Loop
LoopMode.SINGLE -> LlmpIcons.LoopOne
LoopMode.SHUFFLE -> LlmpIcons.Shuffle
},
onClick = {
val next = when (loopMode) {
LoopMode.LIST -> LoopMode.SINGLE
LoopMode.SINGLE -> LoopMode.SHUFFLE
LoopMode.SHUFFLE -> LoopMode.LIST
}
AppContainer.player.setLoopMode(next)
AppContainer.settings.loopMode = next
},
size = 28.dp,
iconSize = 14.dp,
contentPadding = 7.dp,
iconColor = iconTint,
background = controlBg,
)
NeumorphicIconButton(
icon = LlmpIcons.Prev,
onClick = { AppContainer.player.previous() },
size = 44.dp,
iconSize = 14.dp,
radius = 22.dp,
contentPadding = 14.dp,
iconColor = iconTint,
background = controlBg,
)
NeumorphicIconButton(
icon = if (isPlaying && current != null) LlmpIcons.Pause else LlmpIcons.Play,
onClick = { AppContainer.player.toggle() },
size = 56.dp,
iconSize = 20.dp,
radius = 28.dp,
contentPadding = 16.dp,
iconColor = iconTint,
background = controlBg,
)
NeumorphicIconButton(
icon = LlmpIcons.Next,
onClick = { AppContainer.player.next() },
size = 44.dp,
iconSize = 14.dp,
radius = 22.dp,
contentPadding = 14.dp,
iconColor = iconTint,
background = controlBg,
)
NeumorphicIconButton(
icon = LlmpIcons.Playlist,
onClick = onShowPlaylist,
size = 28.dp,
iconSize = 14.dp,
contentPadding = 7.dp,
iconColor = iconTint,
background = controlBg,
)
}
}
Column(
Modifier
.weight(0.58f)
.fillMaxHeight()
.padding(start = 8.dp),
) {
Row(
Modifier.fillMaxWidth().height(40.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (PlatformInfo.allowEulaLyric) {
PlayerFuncButtons(
content = PlayerContent.Lyric,
isLove = isLove,
lrcType = lrcType,
dark = dark,
iconTint = iconTint,
onToggleLove = onToggleLove,
onAddSong = onAddSong,
onToggleTranslate = onToggleTranslate,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
NeumorphicIconButton(
imageVector = Icons.Filled.MoreHoriz,
onClick = onMore,
size = 32.dp,
iconSize = 18.dp,
radius = 6.dp,
contentPadding = 5.dp,
iconColor = iconTint,
background = Color.Transparent,
contentDescription = tr("more"),
)
}
PlayerLyricScroll(
lyric = lyric,
listState = listState,
playingColor = lyricPlayingColor,
otherColor = lyricOtherColor,
onTapBack = {},
onSeek = { positionMs ->
AppContainer.player.seekTo(positionMs)
AppContainer.player.play()
},
modifier = Modifier.weight(1f).fillMaxWidth(),
)
}
}
}
@Composable
private fun Modifier.playerCollapseDrag(): Modifier = pointerInput(Unit) {
var lastDrag = 0f
detectVerticalDragGestures(
onDragEnd = {
AppContainer.weSlide.onChromeVerticalDragEnd?.invoke(lastDrag)
lastDrag = 0f
},
onDragCancel = {
AppContainer.weSlide.onChromeVerticalDragCancel?.invoke()
lastDrag = 0f
},
onVerticalDrag = { change, dragAmount ->
change.consume()
lastDrag = dragAmount
AppContainer.weSlide.onChromeVerticalDrag?.invoke(dragAmount)
},
)
}
@Composable
private fun PlayerHeader(
title: String,
@@ -599,10 +903,11 @@ private fun PlayerLyricScroll(
otherColor: Color,
onTapBack: () -> Unit,
onSeek: (positionMs: Long) -> Unit,
modifier: Modifier = Modifier,
) {
if (lyric.main.isEmpty()) {
Box(
Modifier
modifier
.fillMaxSize()
.pointerInput(onTapBack) {
detectTapGestures(onTap = { onTapBack() })
@@ -675,7 +980,7 @@ private fun PlayerLyricScroll(
selectedIndex = -1
}
BoxWithConstraints(Modifier.fillMaxSize()) {
BoxWithConstraints(modifier.fillMaxSize()) {
// 半视口留白,使首尾行可居中到锚点。
val edgePad = maxHeight / 2
Box(Modifier.fillMaxSize()) {
@@ -865,11 +1170,12 @@ private fun PlayerFuncButtons(
onToggleLove: () -> Unit,
onAddSong: () -> Unit,
onToggleTranslate: () -> Unit,
modifier: Modifier = Modifier,
) {
val btnBg = Color.Transparent
val btnIcon = iconTint
Row(
Modifier
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -944,13 +1250,13 @@ private fun PlayerFuncButtons(
}
@Composable
private fun PlayerCoverArt(music: Music?) {
private fun PlayerCoverArt(music: Music?, size: Dp = 273.dp) {
val dark = LocalDarkTheme.current
val coverUri = rememberMusicCoverUri(music)
val hasCover = !coverUri.isNullOrBlank()
Box(
Modifier
.size(273.dp)
.size(size)
.clip(RoundedCornerShape(24.dp))
.background(if (dark) ColorMs.Dark2B333A else ColorMs.E7F2FF),
contentAlignment = Alignment.Center,
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -73,6 +74,8 @@ fun MiniPlayerBar(
modifier: Modifier = Modifier,
/** 仅选择模式为 0 时可点击封面打开全屏播放器。 */
interactionEnabled: Boolean = true,
/** 嵌进底栏右侧时去掉悬浮胶囊,铺满父级高度。 */
embedded: Boolean = false,
) {
val current by remember {
AppContainer.player.state.map { it.current }.distinctUntilChanged()
@@ -93,11 +96,22 @@ fun MiniPlayerBar(
Box(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 6.dp),
.then(
if (embedded) {
Modifier.fillMaxHeight()
} else {
Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp)
},
),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.then(
if (embedded) {
Modifier.fillMaxHeight()
} else {
Modifier
.height(60.dp)
.clip(RoundedCornerShape(34.dp))
.background(barColor)
@@ -115,6 +129,8 @@ fun MiniPlayerBar(
} else {
Modifier
},
)
},
),
) {
Row(
@@ -142,7 +158,7 @@ fun MiniPlayerBar(
PlayerMusicCover(
music = current,
circular = true,
fallbackPadding = 8.dp,
fallbackPadding = if (embedded) 2.dp else 8.dp,
modifier = Modifier.fillMaxSize(),
)
}
@@ -185,10 +201,10 @@ fun MiniPlayerBar(
Spacer(Modifier.width(8.dp))
// 新拟态按钮 30×30,图标 15,内边距 5,无阴影
MiniBarIconButton(
asset = if (isPlaying) LlmpIcons.Pause else LlmpIcons.Play,
asset = if (isPlaying && current != null) LlmpIcons.Pause else LlmpIcons.Play,
tint = iconColor,
contentDescription = if (isPlaying) tr("pause") else tr("play"),
onClick = if (interactionEnabled) {
contentDescription = if (isPlaying && current != null) tr("pause") else tr("play"),
onClick = if (interactionEnabled && current != null) {
{ AppContainer.player.toggle() }
} else {
null
@@ -84,6 +84,7 @@ import com.zhushenwudi.lovelivemusicplayer.lyric.LoadedLyric
import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser
import com.zhushenwudi.lovelivemusicplayer.platform.DetailBackHandler
import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.player.LoopMode
import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage
import com.zhushenwudi.lovelivemusicplayer.ui.image.clearLlmpImageCache
@@ -109,6 +110,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons
import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset
import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgIcon
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalDesignScale
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp
import kotlinx.coroutines.IO
import kotlin.time.Duration.Companion.milliseconds
@@ -487,9 +489,15 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
Modifier
.fillMaxSize()
.background(bg),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppHeader(title = tr("music_trans"), onBack = { requestBack() })
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (connected || state.completed) {
Spacer(Modifier.height(35.dp))
TransferCoverBlock(
@@ -553,6 +561,7 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
}
}
}
}
if (showLeaveConfirm) {
TwoButtonDialog(
title = tr("terminate_trans"),
@@ -888,6 +897,23 @@ fun DriveModeScreen(onBack: () -> Unit) {
}
}
if (LocalIsLandscape.current) {
DriveModeLandscape(
onBack = onBack,
queue = state.queue,
index = state.index,
isPlaying = state.isPlaying,
loopMode = state.loopMode,
dark = dark,
contentBg = contentBg,
line = line,
musicName = music?.musicName,
musicId = music?.musicId,
isLove = isLove,
)
return
}
// 外层背景 + 内容区背景 + 底部栏外层背景
Column(
Modifier
@@ -993,7 +1019,7 @@ fun DriveModeScreen(onBack: () -> Unit) {
Spacer(Modifier.width(38.dp))
// 播放按钮 90×90,外内边距 8,图标无描边环。
NeumorphicIconButton(
icon = if (state.isPlaying) {
icon = if (state.isPlaying && state.current != null) {
SvgAsset("drawable/drive_car_play_button.svg")
} else {
SvgAsset("drawable/drive_car_pause_button.svg")
@@ -1089,6 +1115,249 @@ fun DriveModeScreen(onBack: () -> Unit) {
}
}
@Composable
private fun DriveModeLandscape(
onBack: () -> Unit,
queue: List<Music>,
index: Int,
isPlaying: Boolean,
loopMode: LoopMode,
dark: Boolean,
contentBg: Color,
line: String,
musicName: String?,
musicId: String?,
isLove: Boolean,
) {
val iconTint = if (dark) ColorMs.White else Color.Black
Column(
Modifier
.fillMaxSize()
.background(contentBg)
.statusBarsPadding()
.navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 16.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
DriveLandscapeAction(
text = tr("exit"),
icon = SvgAsset("drawable/drive_car_exit.svg"),
color = ColorMs.DriveExit,
onClick = onBack,
)
}
Row(
Modifier
.weight(1f)
.fillMaxWidth()
.padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.weight(1.15f)
.fillMaxHeight(),
) {
key(queue.size, queue.firstOrNull()?.musicId, queue.lastOrNull()?.musicId) {
DriveModeCarousel(
queue = queue,
currentIndex = index,
onPageSettled = { page ->
if (page != index) AppContainer.player.playAtIndex(page)
},
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
Column(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(start = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
musicName ?: tr("no_songs"),
color = if (dark) ColorMs.White else ColorMs.Text333,
fontSize = hSp(26f),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Text(
line,
color = if (dark) ColorMs.White else ColorMs.Text333,
fontSize = hSp(18f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(28.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
NeumorphicIconButton(
icon = LlmpIcons.Prev,
onClick = { AppContainer.player.previous() },
size = 64.dp,
iconSize = 22.dp,
radius = 32.dp,
background = contentBg,
iconColor = iconTint,
hasShadow = true,
contentPadding = 18.dp,
)
Spacer(Modifier.width(28.dp))
NeumorphicIconButton(
icon = if (isPlaying && musicId != null) {
SvgAsset("drawable/drive_car_play_button.svg")
} else {
SvgAsset("drawable/drive_car_pause_button.svg")
},
onClick = { AppContainer.player.toggle() },
size = 96.dp,
iconSize = 42.dp,
radius = 48.dp,
background = contentBg,
iconColor = iconTint,
hasShadow = true,
contentPadding = 24.dp,
)
Spacer(Modifier.width(28.dp))
NeumorphicIconButton(
icon = LlmpIcons.Next,
onClick = { AppContainer.player.next() },
size = 64.dp,
iconSize = 22.dp,
radius = 32.dp,
background = contentBg,
iconColor = iconTint,
hasShadow = true,
contentPadding = 18.dp,
)
}
Spacer(Modifier.height(28.dp))
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
NeumorphicIconButton(
icon = if (isLove) {
SvgAsset("drawable/drive_car_favoriate.svg")
} else {
SvgAsset("drawable/drive_car_dis_favorite.svg")
},
onClick = { musicId?.let { AppContainer.toggleLove(it) } },
size = 52.dp,
iconSize = 28.dp,
radius = 26.dp,
background = contentBg,
iconColor = if (isLove) ColorMs.PinkF940A7 else iconTint,
hasShadow = true,
contentPadding = 10.dp,
)
NeumorphicIconButton(
icon = when (loopMode) {
LoopMode.LIST -> LlmpIcons.Loop
LoopMode.SINGLE -> LlmpIcons.LoopOne
LoopMode.SHUFFLE -> LlmpIcons.Shuffle
},
onClick = {
val next = when (loopMode) {
LoopMode.LIST -> LoopMode.SINGLE
LoopMode.SINGLE -> LoopMode.SHUFFLE
LoopMode.SHUFFLE -> LoopMode.LIST
}
AppContainer.settings.loopMode = next
AppContainer.player.setLoopMode(next)
},
size = 52.dp,
iconSize = 24.dp,
radius = 26.dp,
background = contentBg,
iconColor = iconTint,
hasShadow = true,
contentPadding = 12.dp,
)
DriveLandscapeAction(
text = tr("iLove"),
icon = SvgAsset("drawable/drive_car_favorite_bottom.svg"),
color = ColorMs.DriveFavorite,
onClick = {
val loved = AppContainer.library.getAllLove()
.mapNotNull { AppContainer.library.getMusicById(it.musicId) }
if (loved.isNotEmpty()) {
AppContainer.player.setQueue(loved, 0)
AppContainer.player.play()
AppContainer.persistPlayQueue()
}
},
)
DriveLandscapeAction(
text = tr("history_short"),
icon = SvgAsset("drawable/drive_car_playlist_bottom.svg"),
color = ColorMs.DriveHistory,
onClick = {
val recent = AppContainer.library.getHistoryMusic()
if (recent.isNotEmpty()) {
AppContainer.player.setQueue(recent, 0)
AppContainer.player.play()
AppContainer.persistPlayQueue()
}
},
)
}
}
}
}
}
@Composable
private fun DriveLandscapeAction(
text: String,
icon: SvgAsset,
color: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier
.height(52.dp)
.clip(RoundedCornerShape(26.dp))
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onClick,
)
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NeumorphicIconButton(
icon = icon,
onClick = null,
size = 28.dp,
iconSize = 28.dp,
iconColor = color,
hasShadow = false,
contentPadding = 0.dp,
)
Spacer(Modifier.width(8.dp))
Text(text, color = color, fontSize = hSp(16f))
}
}
@Composable
private fun DriveBottomItem(
text: String,
@@ -71,6 +71,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.components.ArtistListItem
import com.zhushenwudi.lovelivemusicplayer.ui.components.ArtistPhotoImage
import com.zhushenwudi.lovelivemusicplayer.ui.components.CoverPlaceholder
import com.zhushenwudi.lovelivemusicplayer.ui.components.DetailsPage
import com.zhushenwudi.lovelivemusicplayer.ui.components.LocalDetailCoverSize
import com.zhushenwudi.lovelivemusicplayer.ui.components.DialogMoreWithMusic
import com.zhushenwudi.lovelivemusicplayer.ui.components.DialogSongInfo
import com.zhushenwudi.lovelivemusicplayer.ui.components.ListItemSubtitleLineHeight
@@ -91,6 +92,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.components.TwoLineListTextColumn
import com.zhushenwudi.lovelivemusicplayer.ui.components.addMusicPlayNext
import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalHomeListBottomInset
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
@Composable
fun LibraryTabContent(
@@ -261,7 +263,11 @@ private fun AlbumList(
modifier = Modifier.fillMaxSize(),
) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
columns = if (LocalIsLandscape.current) {
GridCells.Adaptive(108.dp)
} else {
GridCells.Fixed(3)
},
state = resolvedGridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
@@ -502,6 +508,7 @@ fun AlbumDetailScreen(albumId: String, onBack: () -> Unit, onOpenPlayer: () -> U
onBack = onBack,
onOpenPlayer = onOpenPlayer,
cover = {
val coverSize = LocalDetailCoverSize.current
AlbumDetailsCover(
albumName = album?.albumName.orEmpty(),
category = album?.category,
@@ -510,9 +517,9 @@ fun AlbumDetailScreen(albumId: String, onBack: () -> Unit, onOpenPlayer: () -> U
if (album != null) {
AlbumCoverImage(
album = album,
// 图片高度未指定时回退为宽度(240)。
// 图片高度未指定时回退为宽度。竖屏 240,横屏随左侧栏缩小。
modifier = Modifier
.size(240.dp)
.size(coverSize)
.llmpDetailSharedElement(
HeroKeys.album(albumId),
HeroRoundedClip,
@@ -522,7 +529,7 @@ fun AlbumDetailScreen(albumId: String, onBack: () -> Unit, onOpenPlayer: () -> U
} else {
CoverPlaceholder(
label = tr("album_abbr"),
size = 240.dp,
size = coverSize,
radius = 12.dp,
modifier = Modifier.llmpDetailSharedElement(
HeroKeys.album(albumId),
@@ -551,10 +558,11 @@ fun ArtistDetailScreen(artistId: String, onBack: () -> Unit, onOpenPlayer: () ->
onBack = onBack,
onOpenPlayer = onOpenPlayer,
cover = {
val coverSize = LocalDetailCoverSize.current
if (artist != null) {
ArtistPhotoImage(
artist = artist,
size = 240.dp,
size = coverSize,
modifier = Modifier.llmpDetailSharedElement(
HeroKeys.singer(artistId),
HeroCircleClip,
@@ -563,7 +571,7 @@ fun ArtistDetailScreen(artistId: String, onBack: () -> Unit, onOpenPlayer: () ->
} else {
CoverPlaceholder(
label = tr("music_abbr"),
size = 240.dp,
size = coverSize,
radius = 120.dp,
modifier = Modifier.llmpDetailSharedElement(
HeroKeys.singer(artistId),
@@ -603,10 +611,11 @@ fun MenuDetailScreen(menuId: Long, onBack: () -> Unit, onOpenPlayer: () -> Unit)
songs = AppContainer.library.getMusicByIdsPreservingOrder(nextIds, enableHttp)
},
cover = {
val coverSize = LocalDetailCoverSize.current
if (coverMusic != null) {
MusicCoverImage(
music = coverMusic,
size = 240.dp,
size = coverSize,
radius = 12.dp,
modifier = Modifier.llmpDetailSharedElement(
HeroKeys.menu(menuId),
@@ -616,7 +625,7 @@ fun MenuDetailScreen(menuId: Long, onBack: () -> Unit, onOpenPlayer: () -> Unit)
} else {
CoverPlaceholder(
label = menu?.name?.take(1).orEmpty().ifEmpty { tr("menu_abbr") },
size = 240.dp,
size = coverSize,
radius = 12.dp,
modifier = Modifier.llmpDetailSharedElement(
HeroKeys.menu(menuId),
@@ -29,6 +29,8 @@ import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
import androidx.compose.foundation.layout.navigationBarsPadding
import kotlin.time.Duration.Companion.seconds
/**
@@ -73,18 +75,20 @@ fun SplashScreen(onFinished: () -> Unit) {
.fillMaxSize()
.background(Color.White),
) {
val landscape = LocalIsLandscape.current
val model = photoPath ?: Res.getUri("drawable/launch_background.png")
LlmpAsyncImage(
model = model,
contentDescription = null,
contentScale = if (photoPath != null) ContentScale.Crop else ContentScale.Fit,
alignment = Alignment.TopCenter,
contentScale = if (photoPath != null && !landscape) ContentScale.Crop else ContentScale.Fit,
alignment = if (landscape) Alignment.Center else Alignment.TopCenter,
modifier = Modifier.fillMaxSize(),
)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 25.dp, bottom = 50.dp)
.then(if (landscape) Modifier.navigationBarsPadding() else Modifier)
.padding(end = 25.dp, bottom = if (landscape) 16.dp else 50.dp)
.clip(RoundedCornerShape(20.dp))
.background(ColorMs.PinkF940A7.copy(alpha = 100f / 255f))
.clickable(
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -17,6 +18,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
@@ -28,6 +30,9 @@ import com.zhushenwudi.lovelivemusicplayer.Const
*/
val LocalDesignScale = staticCompositionLocalOf { 1f }
/** 窗口宽大于高。竖屏组合树不读这个值。 */
val LocalIsLandscape = staticCompositionLocalOf { false }
/** [com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideDesignDensity] 缩放前的设备密度(供底部栏使用)。 */
val LocalUnscaledDensity = staticCompositionLocalOf<Density?> { null }
@@ -62,7 +67,10 @@ fun ProvideDesignDensity(content: @Composable () -> Unit) {
return@CompositionLocalProvider
}
val heightDp = heightPx / base.density
val widthPx = containerPx.width
val isLandscape = widthPx > 0 && widthPx > heightPx
// 按高度比例缩放;不低于设计尺寸;平板上限 1.35。忽略系统字体缩放(对齐 Flutter textScaler = 1.0)。
// 横屏高度变矮时下限仍是 1,不会按变矮的高度放大或缩小。
val scale = (heightDp / Const.UI_HEIGHT).coerceIn(1f, 1.35f)
val scaled = remember(base.density, scale) {
Density(
@@ -74,6 +82,7 @@ fun ProvideDesignDensity(content: @Composable () -> Unit) {
LocalDensity provides scaled,
LocalUnscaledDensity provides base,
LocalDesignScale provides scale,
LocalIsLandscape provides isLandscape,
content = content,
)
}
@@ -104,6 +113,18 @@ fun unscaledStatusBarsTop(): Dp = unscaledSystemBarPx(
startupPx = LocalStartupWindowMetrics.current.statusTopPx,
)
/**
* 左右安全区(刘海、手势条),dp 使用真实设备密度,再换算到当前缩放密度。
*/
@Composable
fun unscaledSafeDrawingHorizontal(): Pair<Dp, Dp> {
val density = systemBarDensity()
val insets = WindowInsets.safeDrawing
val start = with(density) { insets.getLeft(this, LayoutDirection.Ltr).toDp() }
val end = with(density) { insets.getRight(this, LayoutDirection.Ltr).toDp() }
return start.asUnscaledInScaledContext() to end.asUnscaledInScaledContext()
}
/** 导航栏底部内边距(dp,使用真实设备密度,忽略设计缩放)。 */
@Composable
fun unscaledNavigationBarsBottom(): Dp = unscaledSystemBarPx(
+4
View File
@@ -167,10 +167,14 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
+4
View File
@@ -85,8 +85,12 @@ targets:
- remote-notification
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
"UISupportedInterfaceOrientations~ipad":
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
NSCameraUsageDescription: 需要获取摄像头权限扫描二维码
NSPhotoLibraryUsageDescription: 需要读取相册获取图片
NSAppTransportSecurity:
@@ -223,6 +223,7 @@ actual class PlayerController(context: Context) {
}
actual fun play() {
if (player.mediaItemCount == 0) return
player.play()
}
@@ -231,6 +232,10 @@ actual class PlayerController(context: Context) {
}
actual fun toggle() {
if (player.mediaItemCount == 0) {
if (player.isPlaying) pause()
return
}
if (player.isPlaying) pause() else play()
}
@@ -646,7 +646,8 @@ actual class PlayerController {
actual fun play() {
ensureAudioReady()
val current = _state.value.current
if (loadedUri.isNullOrBlank() && current != null) {
if (current == null) return
if (loadedUri.isNullOrBlank()) {
playWhenReady = false
AppLog.e(TAG, "play() abort reason=noLoadedUri id=${current.musicId}")
_state.update { it.copy(isPlaying = false, error = "No playable URI") }
@@ -674,6 +675,10 @@ actual class PlayerController {
}
actual fun toggle() {
if (_state.value.current == null) {
if (_state.value.isPlaying) pause()
return
}
if (_state.value.isPlaying) pause() else play()
}