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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@@ -42,6 +44,13 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp 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.AddToMenuDialog
import com.zhushenwudi.lovelivemusicplayer.ui.components.AppDrawer import com.zhushenwudi.lovelivemusicplayer.ui.components.AppDrawer
import com.zhushenwudi.lovelivemusicplayer.ui.components.BatchActionBar 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.LibrarySelectMode
import com.zhushenwudi.lovelivemusicplayer.ui.components.LlmpBottomBar import com.zhushenwudi.lovelivemusicplayer.ui.components.LlmpBottomBar
import com.zhushenwudi.lovelivemusicplayer.ui.components.LoadingOverlay 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.scroll.rememberStatusBarScrollToTopAction
import com.zhushenwudi.lovelivemusicplayer.ui.theme.HomeListChrome import com.zhushenwudi.lovelivemusicplayer.ui.theme.HomeListChrome
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalHomeListBottomInset 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.ProvideUnscaledDensity
import com.zhushenwudi.lovelivemusicplayer.ui.theme.asUnscaledInScaledContext import com.zhushenwudi.lovelivemusicplayer.ui.theme.asUnscaledInScaledContext
import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledNavigationBarsBottom 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.theme.unscaledStatusBarsTop
import com.zhushenwudi.lovelivemusicplayer.ui.weslide.WeSlide import com.zhushenwudi.lovelivemusicplayer.ui.weslide.WeSlide
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
@@ -156,8 +168,10 @@ fun MainShell(
// 选择模式 > 0 时锁定歌库/我的标签与 WeSlide 手势 // 选择模式 > 0 时锁定歌库/我的标签与 WeSlide 手势
val batchLock = selectMode == LibrarySelectMode.Batch val batchLock = selectMode == LibrarySelectMode.Batch
val detailOpen = libraryDetail != null val detailOpen = libraryDetail != null
LaunchedEffect(weSlide.isOpened, detailOpen) { val isLandscape = LocalIsLandscape.current
if (weSlide.isOpened || detailOpen) { LaunchedEffect(weSlide.isOpened, detailOpen, isLandscape) {
// 横屏详情仍保留底栏(三个入口 + 迷你条);全屏播放器展开时收起。
if (weSlide.isOpened || (detailOpen && !isLandscape)) {
weSlideFooter.hide() weSlideFooter.hide()
} else { } else {
weSlideFooter.show() weSlideFooter.show()
@@ -500,6 +514,14 @@ fun MainShell(
} }
val shellBg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary 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 footerNavInset = unscaledNavigationBarsBottom()
val footerHeightUnscaled = when { val footerHeightUnscaled = when {
selectMode == LibrarySelectMode.Batch && batchSelectable -> 102.dp + footerNavInset selectMode == LibrarySelectMode.Batch && batchSelectable -> 102.dp + footerNavInset
@@ -507,7 +529,12 @@ fun MainShell(
} }
val miniPlayerHeight = HomeListChrome.MiniPlayerHeight val miniPlayerHeight = HomeListChrome.MiniPlayerHeight
val footerHeight = footerHeightUnscaled.asUnscaledInScaledContext() val footerHeight = footerHeightUnscaled.asUnscaledInScaledContext()
val panelMinSize = miniPlayerHeight + HomeListChrome.MiniPlayerFooterGap + footerHeight // 横屏迷你条在底栏右侧,收起时面板只露出与底栏等高的一条,由底栏盖住。
val panelMinSize = if (isLandscape) {
footerHeight
} else {
miniPlayerHeight + HomeListChrome.MiniPlayerFooterGap + footerHeight
}
val statusBarTop = unscaledStatusBarsTop().asUnscaledInScaledContext() val statusBarTop = unscaledStatusBarsTop().asUnscaledInScaledContext()
fun importData(force: Boolean) { fun importData(force: Boolean) {
@@ -584,8 +611,13 @@ fun MainShell(
drawerContent = { drawerContent = {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
BoxWithConstraints(Modifier.fillMaxHeight()) { BoxWithConstraints(Modifier.fillMaxHeight()) {
// 与原项目 endDrawer:min(0.35 * 屏高, 屏宽),不用 Material 默认 240–360dp。 // 竖屏与原项目 endDrawer:min(0.35 * 屏高, 屏宽)。
val drawerWidth = min(maxHeight * 0.35f, maxWidth) // 横屏屏高变矮,改用约 300dp,且不超过屏宽的 45%。
val drawerWidth = if (isLandscape) {
min(300.dp, maxWidth * 0.45f)
} else {
min(maxHeight * 0.35f, maxWidth)
}
Surface( Surface(
color = if (dark) ColorMs.NightPrimary else ColorMs.White, color = if (dark) ColorMs.NightPrimary else ColorMs.White,
modifier = Modifier modifier = Modifier
@@ -618,9 +650,42 @@ fun MainShell(
CompositionLocalProvider( CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Ltr, LocalLayoutDirection provides LayoutDirection.Ltr,
LocalMiniPlayerHaze provides if (homeHazeEnabled) miniPlayerHaze else null, LocalMiniPlayerHaze provides if (homeHazeEnabled) miniPlayerHaze else null,
LocalHomeListBottomInset provides HomeListChrome.ListBottomInset, LocalHomeListBottomInset provides if (isLandscape) {
16.dp
} else {
HomeListChrome.ListBottomInset
},
) { ) {
WeSlide( 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, controller = weSlide,
footerController = weSlideFooter, footerController = weSlideFooter,
panelMinSize = panelMinSize, panelMinSize = panelMinSize,
@@ -664,8 +729,49 @@ fun MainShell(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(shellBg) .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( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -704,6 +810,7 @@ fun MainShell(
hasShadow = true, hasShadow = true,
) )
} }
}
if (libraryTab != LibraryTab.ARTISTS && if (libraryTab != LibraryTab.ARTISTS &&
libraryTab != LibraryTab.MENUS libraryTab != LibraryTab.MENUS
@@ -711,7 +818,15 @@ fun MainShell(
SongLibraryTopBar( SongLibraryTopBar(
countLabel = countLabel, countLabel = countLabel,
libraryTab = libraryTab, libraryTab = libraryTab,
selectMode = selectMode, selectMode = if (
isLandscape &&
selectMode == LibrarySelectMode.Search
) {
LibrarySelectMode.Normal
} else {
selectMode
},
showSearch = !isLandscape,
sortAsc = currentSortAsc, sortAsc = currentSortAsc,
searchQuery = searchDraft, searchQuery = searchDraft,
checkedCount = checkedIds.size, checkedCount = checkedIds.size,
@@ -797,65 +912,17 @@ fun MainShell(
} }
// 进入:右滑;退出:页面渐隐 + 封面 Hero(零位移保持转场时长)。 // 进入:右滑;退出:页面渐隐 + 封面 Hero(零位移保持转场时长)。
AnimatedVisibility( // 抽到独立函数,避免外层 Row 把 AnimatedVisibility 解析成行内重载。
LibraryDetailOverlay(
visible = detailOpen, visible = detailOpen,
enter = slideInHorizontally( detail = retainedDetail,
animationSpec = tween( onClose = ::closeLibraryDetail,
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,
onOpenPlayer = { onOpenPlayer = {
closeLibraryDetail() closeLibraryDetail()
weSlide.show() 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) { if (!detailOpen) {
LibraryScrollToTopHandler(libraryTab) LibraryScrollToTopHandler(libraryTab)
@@ -870,11 +937,15 @@ fun MainShell(
} }
} }
}, },
panelHeader = { panelHeader = if (isLandscape) {
null
} else {
{
MiniPlayerBar( MiniPlayerBar(
onOpenPlayer = { weSlide.show() }, onOpenPlayer = { weSlide.show() },
interactionEnabled = !batchLock, interactionEnabled = !batchLock,
) )
}
}, },
panel = { panel = {
FullPlayerScreen( FullPlayerScreen(
@@ -884,8 +955,8 @@ fun MainShell(
}, },
footer = { footer = {
ProvideUnscaledDensity { ProvideUnscaledDensity {
if (detailOpen) { if (detailOpen && !isLandscape) {
// 详情页不画底栏,只保留槽位高度动画给迷你条下移。 // 竖屏详情不画底栏,只保留槽位高度动画给迷你条下移。
} else if (selectMode == LibrarySelectMode.Batch && batchSelectable) { } else if (selectMode == LibrarySelectMode.Batch && batchSelectable) {
BatchActionBar( BatchActionBar(
showUnlove = libraryTab == LibraryTab.LOVE, showUnlove = libraryTab == LibraryTab.LOVE,
@@ -897,6 +968,54 @@ fun MainShell(
if (selectedMusic().isNotEmpty()) showUnloveConfirm = true 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 { } else {
LlmpBottomBar( LlmpBottomBar(
modifier = Modifier.fillMaxSize(), 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -48,6 +49,8 @@ fun LlmpBottomBar(
onSelect: (Int) -> Unit, onSelect: (Int) -> Unit,
onSectionChange: (TopSection) -> Unit, onSectionChange: (TopSection) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/** 横屏底栏宽度够,图标和文字左右排列。 */
horizontalLabels: Boolean = false,
) { ) {
val dark = LocalDarkTheme.current val dark = LocalDarkTheme.current
val tabStripHeight = HomeListChrome.BottomBarContentHeight val tabStripHeight = HomeListChrome.BottomBarContentHeight
@@ -99,10 +102,13 @@ fun LlmpBottomBar(
) { ) {
tabs.forEachIndexed { index, tab -> tabs.forEachIndexed { index, tab ->
val selected = pageSection == section && index == selectedIndex val selected = pageSection == section && index == selectedIndex
Column( val tint = if (selected) ColorMs.PinkF940A7 else ColorMs.UnselectedIcon
horizontalAlignment = Alignment.CenterHorizontally, val labelColor = if (selected) {
verticalArrangement = Arrangement.Center, ColorMs.BottomSelectedText
modifier = Modifier } else {
ColorMs.TabUnselected.copy(alpha = 0.5f)
}
val itemModifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.clickable( .clickable(
@@ -113,11 +119,35 @@ fun LlmpBottomBar(
onSectionChange(pageSection) onSectionChange(pageSection)
} }
onSelect(index) onSelect(index)
}, }
if (horizontalLabels) {
Row(
modifier = itemModifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) { ) {
TintIcon( TintIcon(
asset = tab.icon, 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, size = 18.dp,
contentDescription = tr(tab.titleKey), contentDescription = tr(tab.titleKey),
) )
@@ -125,17 +155,14 @@ fun LlmpBottomBar(
Text( Text(
text = tr(tab.titleKey), text = tr(tab.titleKey),
fontSize = 10.sp, fontSize = 10.sp,
color = if (selected) { color = labelColor,
ColorMs.BottomSelectedText
} else {
ColorMs.TabUnselected.copy(alpha = 0.5f)
},
) )
} }
} }
} }
} }
} }
}
} }
private val BottomTab.icon: SvgAsset private val BottomTab.icon: SvgAsset
@@ -8,10 +8,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.foundation.lazy.itemsIndexed
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.AppContainer
import com.zhushenwudi.lovelivemusicplayer.domain.Music import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
@@ -49,6 +56,9 @@ import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons
/** 对齐 Flutter 末项 `95.h`:只挡住迷你播放器,不再叠加已收起的底栏高度。 */ /** 对齐 Flutter 末项 `95.h`:只挡住迷你播放器,不再叠加已收起的底栏高度。 */
private val DetailsListBottomInset = 95.dp private val DetailsListBottomInset = 95.dp
/** 详情封面边长。竖屏 240;横屏按左侧栏剩余高度缩小。 */
val LocalDetailCoverSize = staticCompositionLocalOf { 240.dp }
/** /**
* 详情页:顶栏、封面、粘性播放栏、歌曲列表。 * 详情页:顶栏、封面、粘性播放栏、歌曲列表。
*/ */
@@ -145,6 +155,38 @@ fun DetailsPage(
.background(scaffoldBg) .background(scaffoldBg)
.graphicsLayer { alpha = bodyAlpha }, .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()) { Column(Modifier.fillMaxSize()) {
AppHeader(title = title, onBack = onBack, modifier = bodyFade) AppHeader(title = title, onBack = onBack, modifier = bodyFade)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -213,6 +255,7 @@ fun DetailsPage(
} }
} }
} }
}
if (selectMode) { if (selectMode) {
BatchActionBar( 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 @Composable
fun DetailsListTop( fun DetailsListTop(
@@ -52,6 +52,7 @@ import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.i18n.tr import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
/** /**
* 双按钮对话框 — 对齐 Flutter [TwoButtonDialog]。 * 双按钮对话框 — 对齐 Flutter [TwoButtonDialog]。
@@ -360,9 +361,15 @@ private fun LlmpDialogShell(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
// 记住首次布局宽度,避免键盘弹出时 adjustResize 缩小 maxHeight 导致弹窗变窄。 // 记住首次布局宽度,避免键盘弹出时 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) min(maxHeight * 0.4f, maxWidth * 0.8f)
} }
}
Column( Column(
modifier = Modifier modifier = Modifier
.width(dialogWidth) .width(dialogWidth)
@@ -8,29 +8,37 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey
import com.zhushenwudi.lovelivemusicplayer.i18n.tr import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.navigation.LibraryTab import com.zhushenwudi.lovelivemusicplayer.navigation.LibraryTab
import com.zhushenwudi.lovelivemusicplayer.navigation.TopSection 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.theme.katongFontFamily
import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons
import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset
import com.zhushenwudi.lovelivemusicplayer.ui.icons.TintIcon
@Composable @Composable
fun TopLibraryTabs( 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") private val FilterIcon = SvgAsset("drawable/main_ic_function.svg")
/** 选择模式:0 正常,1 批量,2 搜索。 */ /** 选择模式:0 正常,1 批量,2 搜索。 */
@@ -119,6 +254,7 @@ fun SongLibraryTopBar(
onSelectAll: (Boolean) -> Unit, onSelectAll: (Boolean) -> Unit,
onBatchFinish: () -> Unit, onBatchFinish: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showSearch: Boolean = true,
) { ) {
when (selectMode) { when (selectMode) {
LibrarySelectMode.Batch -> BatchTopBar( LibrarySelectMode.Batch -> BatchTopBar(
@@ -145,6 +281,7 @@ fun SongLibraryTopBar(
onSearchOpen = onSearchOpen, onSearchOpen = onSearchOpen,
onSort = onSort, onSort = onSort,
onBatchOpen = onBatchOpen, onBatchOpen = onBatchOpen,
showSearch = showSearch,
modifier = modifier, modifier = modifier,
) )
} }
@@ -159,6 +296,7 @@ private fun NormalTopBar(
onSearchOpen: () -> Unit, onSearchOpen: () -> Unit,
onSort: () -> Unit, onSort: () -> Unit,
onBatchOpen: () -> Unit, onBatchOpen: () -> Unit,
showSearch: Boolean = true,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val dark = LocalDarkTheme.current val dark = LocalDarkTheme.current
@@ -180,7 +318,7 @@ private fun NormalTopBar(
maxLines = 1, maxLines = 1,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (!isAlbum) { if (!isAlbum && showSearch) {
NeumorphicIconButton( NeumorphicIconButton(
icon = LlmpIcons.Search, icon = LlmpIcons.Search,
onClick = onSearchOpen, onClick = onSearchOpen,
@@ -68,6 +68,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay 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.PlayerMusicCover
import com.zhushenwudi.lovelivemusicplayer.ui.components.rememberMusicCoverUri import com.zhushenwudi.lovelivemusicplayer.ui.components.rememberMusicCoverUri
import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons 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.abs
import kotlin.math.roundToLong import kotlin.math.roundToLong
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -164,9 +167,10 @@ fun FullPlayerScreen(
if (panelOpen) content = PlayerContent.Cover if (panelOpen) content = PlayerContent.Cover
} }
// 歌词列表:禁用 WeSlide 嵌套/面板拖拽,避免滚动被抢占。 // 歌词列表:禁用 WeSlide 嵌套/面板拖拽,避免滚动被抢占。
// 歌词区与进度条之间的顶栏仍通过 onChromeVerticalDrag 驱动关闭。 // 横屏右侧始终是歌词,拖拽只留在左侧封面区。
LaunchedEffect(content) { val landscape = LocalIsLandscape.current
AppContainer.weSlide.dragEnabled = content == PlayerContent.Cover LaunchedEffect(content, landscape) {
AppContainer.weSlide.dragEnabled = !landscape && content == PlayerContent.Cover
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { AppContainer.weSlide.dragEnabled = true } onDispose { AppContainer.weSlide.dragEnabled = true }
@@ -190,6 +194,42 @@ fun FullPlayerScreen(
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().background(bg)) 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( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -361,7 +401,7 @@ fun FullPlayerScreen(
background = controlBg, background = controlBg,
) )
NeumorphicIconButton( NeumorphicIconButton(
icon = if (isPlaying) LlmpIcons.Pause else LlmpIcons.Play, icon = if (isPlaying && current != null) LlmpIcons.Pause else LlmpIcons.Play,
onClick = { AppContainer.player.toggle() }, onClick = { AppContainer.player.toggle() },
size = 80.dp, size = 80.dp,
iconSize = 26.dp, iconSize = 26.dp,
@@ -394,6 +434,7 @@ fun FullPlayerScreen(
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
} }
} }
}
if (showAddToMenu) { if (showAddToMenu) {
AddToMenuDialog( 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 @Composable
private fun PlayerHeader( private fun PlayerHeader(
title: String, title: String,
@@ -599,10 +903,11 @@ private fun PlayerLyricScroll(
otherColor: Color, otherColor: Color,
onTapBack: () -> Unit, onTapBack: () -> Unit,
onSeek: (positionMs: Long) -> Unit, onSeek: (positionMs: Long) -> Unit,
modifier: Modifier = Modifier,
) { ) {
if (lyric.main.isEmpty()) { if (lyric.main.isEmpty()) {
Box( Box(
Modifier modifier
.fillMaxSize() .fillMaxSize()
.pointerInput(onTapBack) { .pointerInput(onTapBack) {
detectTapGestures(onTap = { onTapBack() }) detectTapGestures(onTap = { onTapBack() })
@@ -675,7 +980,7 @@ private fun PlayerLyricScroll(
selectedIndex = -1 selectedIndex = -1
} }
BoxWithConstraints(Modifier.fillMaxSize()) { BoxWithConstraints(modifier.fillMaxSize()) {
// 半视口留白,使首尾行可居中到锚点。 // 半视口留白,使首尾行可居中到锚点。
val edgePad = maxHeight / 2 val edgePad = maxHeight / 2
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@@ -865,11 +1170,12 @@ private fun PlayerFuncButtons(
onToggleLove: () -> Unit, onToggleLove: () -> Unit,
onAddSong: () -> Unit, onAddSong: () -> Unit,
onToggleTranslate: () -> Unit, onToggleTranslate: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val btnBg = Color.Transparent val btnBg = Color.Transparent
val btnIcon = iconTint val btnIcon = iconTint
Row( Row(
Modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -944,13 +1250,13 @@ private fun PlayerFuncButtons(
} }
@Composable @Composable
private fun PlayerCoverArt(music: Music?) { private fun PlayerCoverArt(music: Music?, size: Dp = 273.dp) {
val dark = LocalDarkTheme.current val dark = LocalDarkTheme.current
val coverUri = rememberMusicCoverUri(music) val coverUri = rememberMusicCoverUri(music)
val hasCover = !coverUri.isNullOrBlank() val hasCover = !coverUri.isNullOrBlank()
Box( Box(
Modifier Modifier
.size(273.dp) .size(size)
.clip(RoundedCornerShape(24.dp)) .clip(RoundedCornerShape(24.dp))
.background(if (dark) ColorMs.Dark2B333A else ColorMs.E7F2FF), .background(if (dark) ColorMs.Dark2B333A else ColorMs.E7F2FF),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -73,6 +74,8 @@ fun MiniPlayerBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/** 仅选择模式为 0 时可点击封面打开全屏播放器。 */ /** 仅选择模式为 0 时可点击封面打开全屏播放器。 */
interactionEnabled: Boolean = true, interactionEnabled: Boolean = true,
/** 嵌进底栏右侧时去掉悬浮胶囊,铺满父级高度。 */
embedded: Boolean = false,
) { ) {
val current by remember { val current by remember {
AppContainer.player.state.map { it.current }.distinctUntilChanged() AppContainer.player.state.map { it.current }.distinctUntilChanged()
@@ -93,11 +96,22 @@ fun MiniPlayerBar(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(
if (embedded) {
Modifier.fillMaxHeight()
} else {
Modifier
.height(60.dp) .height(60.dp)
.clip(RoundedCornerShape(34.dp)) .clip(RoundedCornerShape(34.dp))
.background(barColor) .background(barColor)
@@ -115,6 +129,8 @@ fun MiniPlayerBar(
} else { } else {
Modifier Modifier
}, },
)
},
), ),
) { ) {
Row( Row(
@@ -142,7 +158,7 @@ fun MiniPlayerBar(
PlayerMusicCover( PlayerMusicCover(
music = current, music = current,
circular = true, circular = true,
fallbackPadding = 8.dp, fallbackPadding = if (embedded) 2.dp else 8.dp,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -185,10 +201,10 @@ fun MiniPlayerBar(
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
// 新拟态按钮 30×30,图标 15,内边距 5,无阴影 // 新拟态按钮 30×30,图标 15,内边距 5,无阴影
MiniBarIconButton( MiniBarIconButton(
asset = if (isPlaying) LlmpIcons.Pause else LlmpIcons.Play, asset = if (isPlaying && current != null) LlmpIcons.Pause else LlmpIcons.Play,
tint = iconColor, tint = iconColor,
contentDescription = if (isPlaying) tr("pause") else tr("play"), contentDescription = if (isPlaying && current != null) tr("pause") else tr("play"),
onClick = if (interactionEnabled) { onClick = if (interactionEnabled && current != null) {
{ AppContainer.player.toggle() } { AppContainer.player.toggle() }
} else { } else {
null null
@@ -84,6 +84,7 @@ import com.zhushenwudi.lovelivemusicplayer.lyric.LoadedLyric
import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser
import com.zhushenwudi.lovelivemusicplayer.platform.DetailBackHandler import com.zhushenwudi.lovelivemusicplayer.platform.DetailBackHandler
import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.player.LoopMode import com.zhushenwudi.lovelivemusicplayer.player.LoopMode
import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage
import com.zhushenwudi.lovelivemusicplayer.ui.image.clearLlmpImageCache 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.SvgAsset
import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgIcon import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgIcon
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalDesignScale import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalDesignScale
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -487,9 +489,15 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(bg), .background(bg),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
AppHeader(title = tr("music_trans"), onBack = { requestBack() }) AppHeader(title = tr("music_trans"), onBack = { requestBack() })
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (connected || state.completed) { if (connected || state.completed) {
Spacer(Modifier.height(35.dp)) Spacer(Modifier.height(35.dp))
TransferCoverBlock( TransferCoverBlock(
@@ -553,6 +561,7 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
} }
} }
} }
}
if (showLeaveConfirm) { if (showLeaveConfirm) {
TwoButtonDialog( TwoButtonDialog(
title = tr("terminate_trans"), 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( Column(
Modifier Modifier
@@ -993,7 +1019,7 @@ fun DriveModeScreen(onBack: () -> Unit) {
Spacer(Modifier.width(38.dp)) Spacer(Modifier.width(38.dp))
// 播放按钮 90×90,外内边距 8,图标无描边环。 // 播放按钮 90×90,外内边距 8,图标无描边环。
NeumorphicIconButton( NeumorphicIconButton(
icon = if (state.isPlaying) { icon = if (state.isPlaying && state.current != null) {
SvgAsset("drawable/drive_car_play_button.svg") SvgAsset("drawable/drive_car_play_button.svg")
} else { } else {
SvgAsset("drawable/drive_car_pause_button.svg") 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 @Composable
private fun DriveBottomItem( private fun DriveBottomItem(
text: String, 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.ArtistPhotoImage
import com.zhushenwudi.lovelivemusicplayer.ui.components.CoverPlaceholder import com.zhushenwudi.lovelivemusicplayer.ui.components.CoverPlaceholder
import com.zhushenwudi.lovelivemusicplayer.ui.components.DetailsPage 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.DialogMoreWithMusic
import com.zhushenwudi.lovelivemusicplayer.ui.components.DialogSongInfo import com.zhushenwudi.lovelivemusicplayer.ui.components.DialogSongInfo
import com.zhushenwudi.lovelivemusicplayer.ui.components.ListItemSubtitleLineHeight 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.components.addMusicPlayNext
import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalHomeListBottomInset import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalHomeListBottomInset
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
@Composable @Composable
fun LibraryTabContent( fun LibraryTabContent(
@@ -261,7 +263,11 @@ private fun AlbumList(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(3), columns = if (LocalIsLandscape.current) {
GridCells.Adaptive(108.dp)
} else {
GridCells.Fixed(3)
},
state = resolvedGridState, state = resolvedGridState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
@@ -502,6 +508,7 @@ fun AlbumDetailScreen(albumId: String, onBack: () -> Unit, onOpenPlayer: () -> U
onBack = onBack, onBack = onBack,
onOpenPlayer = onOpenPlayer, onOpenPlayer = onOpenPlayer,
cover = { cover = {
val coverSize = LocalDetailCoverSize.current
AlbumDetailsCover( AlbumDetailsCover(
albumName = album?.albumName.orEmpty(), albumName = album?.albumName.orEmpty(),
category = album?.category, category = album?.category,
@@ -510,9 +517,9 @@ fun AlbumDetailScreen(albumId: String, onBack: () -> Unit, onOpenPlayer: () -> U
if (album != null) { if (album != null) {
AlbumCoverImage( AlbumCoverImage(
album = album, album = album,
// 图片高度未指定时回退为宽度(240)。 // 图片高度未指定时回退为宽度。竖屏 240,横屏随左侧栏缩小。
modifier = Modifier modifier = Modifier
.size(240.dp) .size(coverSize)
.llmpDetailSharedElement( .llmpDetailSharedElement(
HeroKeys.album(albumId), HeroKeys.album(albumId),
HeroRoundedClip, HeroRoundedClip,
@@ -522,7 +529,7 @@ fun AlbumDetailScreen(albumId: String, onBack: () -> Unit, onOpenPlayer: () -> U
} else { } else {
CoverPlaceholder( CoverPlaceholder(
label = tr("album_abbr"), label = tr("album_abbr"),
size = 240.dp, size = coverSize,
radius = 12.dp, radius = 12.dp,
modifier = Modifier.llmpDetailSharedElement( modifier = Modifier.llmpDetailSharedElement(
HeroKeys.album(albumId), HeroKeys.album(albumId),
@@ -551,10 +558,11 @@ fun ArtistDetailScreen(artistId: String, onBack: () -> Unit, onOpenPlayer: () ->
onBack = onBack, onBack = onBack,
onOpenPlayer = onOpenPlayer, onOpenPlayer = onOpenPlayer,
cover = { cover = {
val coverSize = LocalDetailCoverSize.current
if (artist != null) { if (artist != null) {
ArtistPhotoImage( ArtistPhotoImage(
artist = artist, artist = artist,
size = 240.dp, size = coverSize,
modifier = Modifier.llmpDetailSharedElement( modifier = Modifier.llmpDetailSharedElement(
HeroKeys.singer(artistId), HeroKeys.singer(artistId),
HeroCircleClip, HeroCircleClip,
@@ -563,7 +571,7 @@ fun ArtistDetailScreen(artistId: String, onBack: () -> Unit, onOpenPlayer: () ->
} else { } else {
CoverPlaceholder( CoverPlaceholder(
label = tr("music_abbr"), label = tr("music_abbr"),
size = 240.dp, size = coverSize,
radius = 120.dp, radius = 120.dp,
modifier = Modifier.llmpDetailSharedElement( modifier = Modifier.llmpDetailSharedElement(
HeroKeys.singer(artistId), HeroKeys.singer(artistId),
@@ -603,10 +611,11 @@ fun MenuDetailScreen(menuId: Long, onBack: () -> Unit, onOpenPlayer: () -> Unit)
songs = AppContainer.library.getMusicByIdsPreservingOrder(nextIds, enableHttp) songs = AppContainer.library.getMusicByIdsPreservingOrder(nextIds, enableHttp)
}, },
cover = { cover = {
val coverSize = LocalDetailCoverSize.current
if (coverMusic != null) { if (coverMusic != null) {
MusicCoverImage( MusicCoverImage(
music = coverMusic, music = coverMusic,
size = 240.dp, size = coverSize,
radius = 12.dp, radius = 12.dp,
modifier = Modifier.llmpDetailSharedElement( modifier = Modifier.llmpDetailSharedElement(
HeroKeys.menu(menuId), HeroKeys.menu(menuId),
@@ -616,7 +625,7 @@ fun MenuDetailScreen(menuId: Long, onBack: () -> Unit, onOpenPlayer: () -> Unit)
} else { } else {
CoverPlaceholder( CoverPlaceholder(
label = menu?.name?.take(1).orEmpty().ifEmpty { tr("menu_abbr") }, label = menu?.name?.take(1).orEmpty().ifEmpty { tr("menu_abbr") },
size = 240.dp, size = coverSize,
radius = 12.dp, radius = 12.dp,
modifier = Modifier.llmpDetailSharedElement( modifier = Modifier.llmpDetailSharedElement(
HeroKeys.menu(menuId), HeroKeys.menu(menuId),
@@ -29,6 +29,8 @@ import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
import com.zhushenwudi.lovelivemusicplayer.i18n.tr import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage 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 import kotlin.time.Duration.Companion.seconds
/** /**
@@ -73,18 +75,20 @@ fun SplashScreen(onFinished: () -> Unit) {
.fillMaxSize() .fillMaxSize()
.background(Color.White), .background(Color.White),
) { ) {
val landscape = LocalIsLandscape.current
val model = photoPath ?: Res.getUri("drawable/launch_background.png") val model = photoPath ?: Res.getUri("drawable/launch_background.png")
LlmpAsyncImage( LlmpAsyncImage(
model = model, model = model,
contentDescription = null, contentDescription = null,
contentScale = if (photoPath != null) ContentScale.Crop else ContentScale.Fit, contentScale = if (photoPath != null && !landscape) ContentScale.Crop else ContentScale.Fit,
alignment = Alignment.TopCenter, alignment = if (landscape) Alignment.Center else Alignment.TopCenter,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .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)) .clip(RoundedCornerShape(20.dp))
.background(ColorMs.PinkF940A7.copy(alpha = 100f / 255f)) .background(ColorMs.PinkF940A7.copy(alpha = 100f / 255f))
.clickable( .clickable(
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider 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.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -28,6 +30,9 @@ import com.zhushenwudi.lovelivemusicplayer.Const
*/ */
val LocalDesignScale = staticCompositionLocalOf { 1f } val LocalDesignScale = staticCompositionLocalOf { 1f }
/** 窗口宽大于高。竖屏组合树不读这个值。 */
val LocalIsLandscape = staticCompositionLocalOf { false }
/** [com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideDesignDensity] 缩放前的设备密度(供底部栏使用)。 */ /** [com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideDesignDensity] 缩放前的设备密度(供底部栏使用)。 */
val LocalUnscaledDensity = staticCompositionLocalOf<Density?> { null } val LocalUnscaledDensity = staticCompositionLocalOf<Density?> { null }
@@ -62,7 +67,10 @@ fun ProvideDesignDensity(content: @Composable () -> Unit) {
return@CompositionLocalProvider return@CompositionLocalProvider
} }
val heightDp = heightPx / base.density val heightDp = heightPx / base.density
val widthPx = containerPx.width
val isLandscape = widthPx > 0 && widthPx > heightPx
// 按高度比例缩放;不低于设计尺寸;平板上限 1.35。忽略系统字体缩放(对齐 Flutter textScaler = 1.0)。 // 按高度比例缩放;不低于设计尺寸;平板上限 1.35。忽略系统字体缩放(对齐 Flutter textScaler = 1.0)。
// 横屏高度变矮时下限仍是 1,不会按变矮的高度放大或缩小。
val scale = (heightDp / Const.UI_HEIGHT).coerceIn(1f, 1.35f) val scale = (heightDp / Const.UI_HEIGHT).coerceIn(1f, 1.35f)
val scaled = remember(base.density, scale) { val scaled = remember(base.density, scale) {
Density( Density(
@@ -74,6 +82,7 @@ fun ProvideDesignDensity(content: @Composable () -> Unit) {
LocalDensity provides scaled, LocalDensity provides scaled,
LocalUnscaledDensity provides base, LocalUnscaledDensity provides base,
LocalDesignScale provides scale, LocalDesignScale provides scale,
LocalIsLandscape provides isLandscape,
content = content, content = content,
) )
} }
@@ -104,6 +113,18 @@ fun unscaledStatusBarsTop(): Dp = unscaledSystemBarPx(
startupPx = LocalStartupWindowMetrics.current.statusTopPx, 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,使用真实设备密度,忽略设计缩放)。 */ /** 导航栏底部内边距(dp,使用真实设备密度,忽略设计缩放)。 */
@Composable @Composable
fun unscaledNavigationBarsBottom(): Dp = unscaledSystemBarPx( fun unscaledNavigationBarsBottom(): Dp = unscaledSystemBarPx(
+4
View File
@@ -167,10 +167,14 @@
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true/>
+4
View File
@@ -85,8 +85,12 @@ targets:
- remote-notification - remote-notification
UISupportedInterfaceOrientations: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
"UISupportedInterfaceOrientations~ipad": "UISupportedInterfaceOrientations~ipad":
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
NSCameraUsageDescription: 需要获取摄像头权限扫描二维码 NSCameraUsageDescription: 需要获取摄像头权限扫描二维码
NSPhotoLibraryUsageDescription: 需要读取相册获取图片 NSPhotoLibraryUsageDescription: 需要读取相册获取图片
NSAppTransportSecurity: NSAppTransportSecurity:
@@ -223,6 +223,7 @@ actual class PlayerController(context: Context) {
} }
actual fun play() { actual fun play() {
if (player.mediaItemCount == 0) return
player.play() player.play()
} }
@@ -231,6 +232,10 @@ actual class PlayerController(context: Context) {
} }
actual fun toggle() { actual fun toggle() {
if (player.mediaItemCount == 0) {
if (player.isPlaying) pause()
return
}
if (player.isPlaying) pause() else play() if (player.isPlaying) pause() else play()
} }
@@ -646,7 +646,8 @@ actual class PlayerController {
actual fun play() { actual fun play() {
ensureAudioReady() ensureAudioReady()
val current = _state.value.current val current = _state.value.current
if (loadedUri.isNullOrBlank() && current != null) { if (current == null) return
if (loadedUri.isNullOrBlank()) {
playWhenReady = false playWhenReady = false
AppLog.e(TAG, "play() abort reason=noLoadedUri id=${current.musicId}") AppLog.e(TAG, "play() abort reason=noLoadedUri id=${current.musicId}")
_state.update { it.copy(isPlaying = false, error = "No playable URI") } _state.update { it.copy(isPlaying = false, error = "No playable URI") }
@@ -674,6 +675,10 @@ actual class PlayerController {
} }
actual fun toggle() { actual fun toggle() {
if (_state.value.current == null) {
if (_state.value.isPlaying) pause()
return
}
if (_state.value.isPlaying) pause() else play() if (_state.value.isPlaying) pause() else play()
} }