replace app icon and fix bugs

This commit is contained in:
2026-10-03 22:20:42 +08:00
parent 53897bda1a
commit 7b856f574e
55 changed files with 631 additions and 182 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,5 @@
package com.zhushenwudi.lovelivemusicplayer.ui.player
import androidx.compose.ui.text.font.FontFamily
internal actual fun emptyPlayerTitleFontFamily(): FontFamily = FontFamily.Default
Binary file not shown.

Before

Width:  |  Height:  |  Size: 724 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

@@ -87,16 +87,15 @@ 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.DrawerLogoButton
import com.zhushenwudi.lovelivemusicplayer.ui.components.LandscapeLibraryHeader 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
import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicGroupLogoButton
import com.zhushenwudi.lovelivemusicplayer.ui.components.SongLibraryTopBar import com.zhushenwudi.lovelivemusicplayer.ui.components.SongLibraryTopBar
import com.zhushenwudi.lovelivemusicplayer.ui.components.TopLibraryTabs import com.zhushenwudi.lovelivemusicplayer.ui.components.TopLibraryTabs
import com.zhushenwudi.lovelivemusicplayer.ui.components.TwoButtonDialog import com.zhushenwudi.lovelivemusicplayer.ui.components.TwoButtonDialog
import com.zhushenwudi.lovelivemusicplayer.ui.components.UpdateVersionDialog import com.zhushenwudi.lovelivemusicplayer.ui.components.UpdateVersionDialog
import com.zhushenwudi.lovelivemusicplayer.ui.components.isDefaultGroupLogo
import com.zhushenwudi.lovelivemusicplayer.ui.player.FullPlayerScreen import com.zhushenwudi.lovelivemusicplayer.ui.player.FullPlayerScreen
import com.zhushenwudi.lovelivemusicplayer.ui.player.LocalMiniPlayerHaze import com.zhushenwudi.lovelivemusicplayer.ui.player.LocalMiniPlayerHaze
import com.zhushenwudi.lovelivemusicplayer.ui.player.MiniPlayerBar import com.zhushenwudi.lovelivemusicplayer.ui.player.MiniPlayerBar
@@ -761,7 +760,6 @@ fun MainShell(
} }
} }
}, },
group = currentGroup,
onOpenDrawer = if (batchLock || detailOpen) { onOpenDrawer = if (batchLock || detailOpen) {
null null
} else { } else {
@@ -788,26 +786,13 @@ fun MainShell(
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
// 顶部团组 Logo 按钮,随 currentGroup 变化。 DrawerLogoButton(
NeumorphicGroupLogoButton(
group = currentGroup,
onClick = if (batchLock || detailOpen) { onClick = if (batchLock || detailOpen) {
null null
} else { } else {
{ scope.launch { drawerState.open() } } { scope.launch { drawerState.open() } }
}, },
size = 36.dp, background = shellBg,
background = if (currentGroup.isDefaultGroupLogo()) {
Color(0x4DFFAE00)
} else {
shellBg
},
contentPadding = if (currentGroup.isDefaultGroupLogo()) {
3.dp
} else {
0.dp
},
hasShadow = true,
) )
} }
} }
@@ -1,5 +1,6 @@
package com.zhushenwudi.lovelivemusicplayer.ui.components package com.zhushenwudi.lovelivemusicplayer.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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
@@ -36,6 +37,9 @@ import coil3.compose.AsyncImage
import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.ExperimentalResourceApi
import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey
import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
import com.zhushenwudi.lovelivemusicplayer.generated.resources.llmp_logo_dark
import com.zhushenwudi.lovelivemusicplayer.generated.resources.llmp_logo_light
import org.jetbrains.compose.resources.painterResource
import com.zhushenwudi.lovelivemusicplayer.i18n.tr import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.navigation.Routes import com.zhushenwudi.lovelivemusicplayer.navigation.Routes
import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
@@ -61,7 +65,7 @@ enum class DrawerAction {
/** 与 Flutter `DrawerLayout` 对齐的抽屉尺寸。 */ /** 与 Flutter `DrawerLayout` 对齐的抽屉尺寸。 */
private object DrawerLayout { private object DrawerLayout {
val logoSlotHeight = 130.dp val logoSlotHeight = 130.dp
val defaultLogoSize = 165.dp val defaultLogoSize = 120.dp
val groupLogoSize = 130.dp val groupLogoSize = 130.dp
val logoToTitleGap = 5.dp val logoToTitleGap = 5.dp
val titleFontSize = 17.3.sp val titleFontSize = 17.3.sp
@@ -187,10 +191,21 @@ fun AppDrawer(
.fillMaxWidth(), .fillMaxWidth(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
GroupLogoImage( if (currentGroup.isDefaultGroupLogo()) {
group = currentGroup, Image(
modifier = Modifier.size(topLogoSize), painter = painterResource(
) if (dark) Res.drawable.llmp_logo_dark else Res.drawable.llmp_logo_light,
),
contentDescription = "LLMP",
contentScale = ContentScale.Fit,
modifier = Modifier.size(topLogoSize).clip(RoundedCornerShape(24.dp)),
)
} else {
GroupLogoImage(
group = currentGroup,
modifier = Modifier.size(topLogoSize),
)
}
} }
Spacer(Modifier.height(DrawerLayout.logoToTitleGap)) Spacer(Modifier.height(DrawerLayout.logoToTitleGap))
Text( Text(
@@ -0,0 +1,35 @@
package com.zhushenwudi.lovelivemusicplayer.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
import com.zhushenwudi.lovelivemusicplayer.generated.resources.llmp_logo_dark
import com.zhushenwudi.lovelivemusicplayer.generated.resources.llmp_logo_light
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme
/** 横竖屏共用的抽屉入口,跟随应用主题(包括手动主题设置)。 */
@Composable
fun DrawerLogoButton(
onClick: (() -> Unit)?,
background: Color,
modifier: Modifier = Modifier,
) {
NeumorphicDrawableButton(
drawable = if (LocalDarkTheme.current) {
Res.drawable.llmp_logo_dark
} else {
Res.drawable.llmp_logo_light
},
onClick = onClick,
modifier = modifier,
size = 36.dp,
radius = 8.dp,
background = background,
contentScale = ContentScale.Fit,
contentDescription = tr("open_drawer"),
)
}
@@ -136,6 +136,7 @@ fun NeumorphicDrawableButton(
hasShadow: Boolean = true, hasShadow: Boolean = true,
contentPadding: Dp = 0.dp, contentPadding: Dp = 0.dp,
contentScale: ContentScale = ContentScale.Crop, contentScale: ContentScale = ContentScale.Crop,
contentDescription: String? = null,
) { ) {
val shape = if (radius >= size / 2) CircleShape else RoundedCornerShape(radius) val shape = if (radius >= size / 2) CircleShape else RoundedCornerShape(radius)
NeumorphicSurface( NeumorphicSurface(
@@ -151,7 +152,7 @@ fun NeumorphicDrawableButton(
key(drawable) { key(drawable) {
androidx.compose.foundation.Image( androidx.compose.foundation.Image(
painter = painterResource(drawable), painter = painterResource(drawable),
contentDescription = null, contentDescription = contentDescription,
contentScale = contentScale, contentScale = contentScale,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -0,0 +1,78 @@
package com.zhushenwudi.lovelivemusicplayer.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
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.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
/** 横屏图示与操作分栏;窄窗口仍单列滚动,避免操作被固定大图/间距挤出屏幕。 */
@Composable
internal fun PairingPageLayout(
modifier: Modifier = Modifier,
illustration: @Composable (Dp) -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
val landscape = LocalIsLandscape.current
BoxWithConstraints(
modifier.fillMaxSize().windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom),
),
) {
if (landscape && maxWidth >= 560.dp) {
val viewportHeight = maxHeight
val illustrationSize = minOf(maxHeight * 0.8f, maxWidth * 0.4f, 280.dp)
Row(Modifier.fillMaxSize().padding(horizontal = 24.dp)) {
Box(
Modifier.weight(1f).fillMaxHeight(),
contentAlignment = Alignment.Center,
) {
illustration(illustrationSize)
}
Column(
Modifier.weight(1f).fillMaxHeight()
.verticalScroll(rememberScrollState())
.heightIn(min = viewportHeight).padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
content = content,
)
}
} else {
val illustrationSize = minOf(maxWidth - 48.dp, 343.dp).coerceAtLeast(1.dp)
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
illustration(illustrationSize)
}
Spacer(Modifier.height(32.dp))
content()
Spacer(Modifier.height(24.dp))
}
}
}
}
@@ -38,7 +38,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.LineHeightStyle 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
@@ -114,7 +113,6 @@ fun LandscapeLibraryHeader(
interactionEnabled: Boolean, interactionEnabled: Boolean,
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
group: GroupKey,
onOpenDrawer: (() -> Unit)?, onOpenDrawer: (() -> Unit)?,
shellBg: Color, shellBg: Color,
showSearch: Boolean, showSearch: Boolean,
@@ -219,13 +217,9 @@ fun LandscapeLibraryHeader(
} }
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
NeumorphicGroupLogoButton( DrawerLogoButton(
group = group,
onClick = onOpenDrawer, onClick = onOpenDrawer,
size = 36.dp, background = shellBg,
background = if (group.isDefaultGroupLogo()) Color(0x4DFFAE00) else shellBg,
contentPadding = if (group.isDefaultGroupLogo()) 3.dp else 0.dp,
hasShadow = true,
) )
} }
} }
@@ -0,0 +1,6 @@
package com.zhushenwudi.lovelivemusicplayer.ui.player
import androidx.compose.ui.text.font.FontFamily
/** 空状态标题字体;iOS 明确选择中文字族,避免粗体逐字回退造成视觉深浅不一。 */
internal expect fun emptyPlayerTitleFontFamily(): FontFamily
@@ -40,6 +40,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -166,6 +167,7 @@ fun MiniPlayerBar(
MiniPlayerTitle( MiniPlayerTitle(
text = title, text = title,
color = titleColor, color = titleColor,
fontFamily = if (current == null) emptyPlayerTitleFontFamily() else null,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.then( .then(
@@ -248,10 +250,11 @@ private const val MiniTitleMarqueeHoldMs = 2_000
private fun MiniPlayerTitle( private fun MiniPlayerTitle(
text: String, text: String,
color: Color, color: Color,
fontFamily: FontFamily?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val textMeasurer = rememberTextMeasurer() val textMeasurer = rememberTextMeasurer()
val style = MiniTitleStyle.copy(color = color) val style = MiniTitleStyle.copy(color = color, fontFamily = fontFamily)
BoxWithConstraints( BoxWithConstraints(
modifier = modifier.clipToBounds(), modifier = modifier.clipToBounds(),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
@@ -8,6 +8,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
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.fillMaxHeight
@@ -64,6 +65,7 @@ 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.text.withLink import androidx.compose.ui.text.withLink
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.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -99,6 +101,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.components.DriveModeCarousel
import com.zhushenwudi.lovelivemusicplayer.ui.components.LoadingOverlay import com.zhushenwudi.lovelivemusicplayer.ui.components.LoadingOverlay
import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicIconButton import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicIconButton
import com.zhushenwudi.lovelivemusicplayer.ui.components.PairTipsBlock import com.zhushenwudi.lovelivemusicplayer.ui.components.PairTipsBlock
import com.zhushenwudi.lovelivemusicplayer.ui.components.PairingPageLayout
import com.zhushenwudi.lovelivemusicplayer.ui.components.PinkFuncButton import com.zhushenwudi.lovelivemusicplayer.ui.components.PinkFuncButton
import com.zhushenwudi.lovelivemusicplayer.ui.components.ResetDataDialog import com.zhushenwudi.lovelivemusicplayer.ui.components.ResetDataDialog
import com.zhushenwudi.lovelivemusicplayer.ui.components.TextFieldDialog import com.zhushenwudi.lovelivemusicplayer.ui.components.TextFieldDialog
@@ -395,48 +398,47 @@ fun ScanScreen(onBack: () -> Unit, onConnectedHost: (String) -> Unit) {
var host by remember { mutableStateOf(AppContainer.settings.lastPcHost) } var host by remember { mutableStateOf(AppContainer.settings.lastPcHost) }
val dark = LocalDarkTheme.current val dark = LocalDarkTheme.current
val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary
var active by remember { mutableStateOf(true) }
DisposableEffect(Unit) { onDispose { active = false } }
fun connect(raw: String) {
val parsed = parseHost(raw)
if (parsed.isBlank()) return
host = parsed
AppContainer.settings.lastPcHost = parsed
onConnectedHost(parsed)
}
fun scan() {
PlatformUi.openQrScanner { result ->
if (active && result.isNotBlank()) connect(result)
}
}
Scaffold( Scaffold(
containerColor = bg, containerColor = bg,
topBar = { AppHeader(title = tr("scan_pair"), onBack = onBack) }, topBar = { AppHeader(title = tr("scan_pair"), onBack = onBack) },
) { padding -> ) { padding ->
Column( PairingPageLayout(
Modifier modifier = Modifier.padding(padding).consumeWindowInsets(padding).imePadding(),
.padding(padding) illustration = { size ->
.fillMaxSize() Box(
.imePadding() Modifier
.verticalScroll(rememberScrollState()) .size(minOf(size, 250.dp))
.padding(horizontal = 24.dp), .clip(RoundedCornerShape(12.dp))
horizontalAlignment = Alignment.CenterHorizontally, .background(if (dark) ColorMs.Dark1E2328 else ColorMs.E7F2FF)
verticalArrangement = Arrangement.Center, .clickable { scan() },
) { contentAlignment = Alignment.Center,
Box( ) {
Modifier Column(horizontalAlignment = Alignment.CenterHorizontally) {
.size(250.dp) SvgIcon(
.clip(RoundedCornerShape(12.dp)) asset = SvgAsset("drawable/sync_icon_scan_qr.svg"),
.background(if (dark) ColorMs.Dark1E2328 else ColorMs.E7F2FF) size = 64.dp,
.clickable { tint = ColorMs.PinkF940A7,
com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi.openQrScanner { result -> )
val parsed = parseHost(result) Spacer(Modifier.height(12.dp))
if (parsed.isNotBlank()) { Text(tr("tap_to_scan"), color = ColorMs.Gray999, fontSize = hSp(13f))
host = parsed }
AppContainer.settings.lastPcHost = parsed
onConnectedHost(parsed)
}
}
},
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
SvgIcon(
asset = SvgAsset("drawable/sync_icon_scan_qr.svg"),
size = 64.dp,
tint = ColorMs.PinkF940A7,
)
Spacer(Modifier.height(12.dp))
Text(tr("tap_to_scan"), color = ColorMs.Gray999, fontSize = hSp(13f))
} }
} },
Spacer(Modifier.height(24.dp)) ) {
OutlinedTextField( OutlinedTextField(
value = host, value = host,
onValueChange = { host = it.trim() }, onValueChange = { host = it.trim() },
@@ -447,10 +449,7 @@ fun ScanScreen(onBack: () -> Unit, onConnectedHost: (String) -> Unit) {
PinkFuncButton( PinkFuncButton(
title = tr("confirm_pair"), title = tr("confirm_pair"),
icon = SvgAsset("drawable/sync_icon_scan_qr.svg"), icon = SvgAsset("drawable/sync_icon_scan_qr.svg"),
onClick = { onClick = { connect(host) },
AppContainer.settings.lastPcHost = host
onConnectedHost(host)
},
) )
} }
} }
@@ -488,75 +487,69 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
.background(bg), .background(bg),
) { ) {
AppHeader(title = tr("music_trans"), onBack = { requestBack() }) AppHeader(title = tr("music_trans"), onBack = { requestBack() })
Column( PairingPageLayout(
Modifier modifier = Modifier.weight(1f),
.weight(1f) illustration = { size ->
.fillMaxWidth() if (connected || state.completed) {
.verticalScroll(rememberScrollState()), TransferCoverBlock(
horizontalAlignment = Alignment.CenterHorizontally, coverUrl = state.coverUrl,
coverKey = state.coverKey,
song = state.current,
artist = state.artist,
size = size,
onIdleChange = { coverAnimationsIdle = it },
onDisplayedSongChange = { song, artist ->
displayedSong = song
displayedArtist = artist
},
)
} else {
SvgIcon(asset = SvgAsset("drawable/sync_icon_data_sync.svg"), size = minOf(size, 300.dp))
}
},
) { ) {
if (connected || state.completed) { if (connected || state.completed) {
Spacer(Modifier.height(35.dp)) Column(
TransferCoverBlock( Modifier.height(60.dp),
coverUrl = state.coverUrl, horizontalAlignment = Alignment.CenterHorizontally,
coverKey = state.coverKey, verticalArrangement = Arrangement.Center,
song = state.current, ) {
artist = state.artist, if (displayedSong.isNotBlank()) {
onIdleChange = { coverAnimationsIdle = it }, Text(
onDisplayedSongChange = { song, artist -> displayedSong,
displayedSong = song color = if (dark) ColorMs.White else ColorMs.Text333,
displayedArtist = artist fontSize = hSp(15f),
}, maxLines = 1,
) overflow = TextOverflow.Ellipsis,
Spacer(Modifier.height(20.dp)) )
Column( Text(
Modifier.height(60.dp), displayedArtist,
horizontalAlignment = Alignment.CenterHorizontally, color = ColorMs.Gray999,
verticalArrangement = Arrangement.Center, fontSize = hSp(12f),
) { maxLines = 1,
if (displayedSong.isNotBlank()) { overflow = TextOverflow.Ellipsis,
Text( )
displayedSong, }
color = if (dark) ColorMs.White else ColorMs.Text333, }
fontSize = hSp(15f), Spacer(Modifier.height(24.dp))
maxLines = 1, TransferProgressRing(
overflow = TextOverflow.Ellipsis, progress = state.progress,
) total = state.total,
Text( downloading = state.total > 0,
displayedArtist, )
color = ColorMs.Gray999, } else {
fontSize = hSp(12f), PairTipsBlock()
maxLines = 1, Spacer(Modifier.height(24.dp))
overflow = TextOverflow.Ellipsis, PinkFuncButton(
) title = tr("device_pair"),
icon = SvgAsset("drawable/sync_icon_scan_qr.svg"),
onClick = onOpenScan,
)
state.error?.let {
Spacer(Modifier.height(16.dp))
Text(it, color = ColorMs.PinkF940A7, fontSize = hSp(12f))
} }
} }
Spacer(Modifier.height(40.dp))
TransferProgressRing(
progress = state.progress,
total = state.total,
downloading = state.total > 0,
)
} else {
Spacer(Modifier.height(15.dp))
SvgIcon(
asset = SvgAsset("drawable/sync_icon_data_sync.svg"),
size = 300.dp,
contentDescription = null,
)
Spacer(Modifier.height(65.dp))
PairTipsBlock()
Spacer(Modifier.height(85.dp))
PinkFuncButton(
title = tr("device_pair"),
icon = SvgAsset("drawable/sync_icon_scan_qr.svg"),
onClick = onOpenScan,
)
state.error?.let {
Spacer(Modifier.height(16.dp))
Text(it, color = ColorMs.PinkF940A7, fontSize = hSp(12f))
}
}
} }
} }
if (showLeaveConfirm) { if (showLeaveConfirm) {
@@ -588,6 +581,7 @@ private fun TransferCoverBlock(
coverKey: String, coverKey: String,
song: String, song: String,
artist: String, artist: String,
size: Dp,
onIdleChange: (Boolean) -> Unit, onIdleChange: (Boolean) -> Unit,
onDisplayedSongChange: (String, String) -> Unit, onDisplayedSongChange: (String, String) -> Unit,
) { ) {
@@ -644,11 +638,12 @@ private fun TransferCoverBlock(
userScrollEnabled = false, userScrollEnabled = false,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(343.dp), .height(size),
) { page -> ) { page ->
val cover = covers[page] val cover = covers[page]
TransferCoverCircle( TransferCoverCircle(
cover = cover, cover = cover,
size = minOf(CoverCarouselSize, size * 0.8f),
onReady = { onReady = {
if (cover.pageId !in readyCoverPages) { if (cover.pageId !in readyCoverPages) {
readyCoverPages.add(cover.pageId) readyCoverPages.add(cover.pageId)
@@ -670,13 +665,14 @@ private data class TransferCoverPage(
@Composable @Composable
private fun TransferCoverCircle( private fun TransferCoverCircle(
cover: TransferCoverPage, cover: TransferCoverPage,
size: Dp,
onReady: () -> Unit, onReady: () -> Unit,
) { ) {
var loadFailed by remember(cover.url) { mutableStateOf(false) } var loadFailed by remember(cover.url) { mutableStateOf(false) }
val latestOnReady by rememberUpdatedState(onReady) val latestOnReady by rememberUpdatedState(onReady)
Box( Box(
Modifier Modifier
.size(CoverCarouselSize) .size(size)
.clip(CircleShape), .clip(CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -698,7 +694,7 @@ private fun TransferCoverCircle(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
stableCacheKey = cover.key, stableCacheKey = cover.key,
decodeSize = CoverCarouselSize, decodeSize = size,
onSuccess = { latestOnReady() }, onSuccess = { latestOnReady() },
onError = { onError = {
loadFailed = true loadFailed = true
@@ -781,27 +777,23 @@ fun DataSyncScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
containerColor = bg, containerColor = bg,
topBar = { AppHeader(title = tr("data_sync"), onBack = onBack) }, topBar = { AppHeader(title = tr("data_sync"), onBack = onBack) },
) { padding -> ) { padding ->
Column( PairingPageLayout(
Modifier modifier = Modifier.padding(padding).consumeWindowInsets(padding),
.padding(padding) illustration = { size ->
.fillMaxSize() Box(Modifier.size(size), contentAlignment = Alignment.Center) {
.verticalScroll(rememberScrollState()), if (connected) {
horizontalAlignment = Alignment.CenterHorizontally, WaterRipplePlaceholder(size)
) { } else {
Spacer(Modifier.height(15.dp)) SvgIcon(
Box(Modifier.size(343.dp), contentAlignment = Alignment.Center) { asset = SvgAsset("drawable/sync_icon_data_sync.svg"),
if (connected) { size = minOf(size, 300.dp),
WaterRipplePlaceholder() )
} else { }
SvgIcon(
asset = SvgAsset("drawable/sync_icon_data_sync.svg"),
size = 300.dp,
)
} }
} },
Spacer(Modifier.height(20.dp)) ) {
PairTipsBlock() PairTipsBlock()
Spacer(Modifier.height(60.dp)) Spacer(Modifier.height(24.dp))
if (connected) { if (connected) {
PinkFuncButton( PinkFuncButton(
title = tr("phone2pc"), title = tr("phone2pc"),
@@ -859,19 +851,19 @@ fun DataSyncScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
} }
@Composable @Composable
private fun WaterRipplePlaceholder() { private fun WaterRipplePlaceholder(size: Dp) {
Box(Modifier.size(343.dp), contentAlignment = Alignment.Center) { Box(Modifier.size(size), contentAlignment = Alignment.Center) {
listOf(0.35f, 0.55f, 0.75f, 0.95f).forEach { scale -> listOf(0.35f, 0.55f, 0.75f, 0.95f).forEach { scale ->
Box( Box(
Modifier Modifier
.size((343 * scale).dp) .size(size * scale)
.clip(CircleShape) .clip(CircleShape)
.background(ColorMs.PinkF940A7.copy(alpha = 0.08f + (1f - scale) * 0.08f)), .background(ColorMs.PinkF940A7.copy(alpha = 0.08f + (1f - scale) * 0.08f)),
) )
} }
SvgIcon( SvgIcon(
asset = SvgAsset("drawable/sync_icon_data_sync.svg"), asset = SvgAsset("drawable/sync_icon_data_sync.svg"),
size = 96.dp, size = size * (96f / 343f),
tint = ColorMs.PinkF940A7, tint = ColorMs.PinkF940A7,
) )
} }
@@ -1,11 +1,14 @@
package com.zhushenwudi.lovelivemusicplayer.ui.screens package com.zhushenwudi.lovelivemusicplayer.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -23,11 +26,14 @@ import androidx.compose.ui.layout.ContentScale
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
import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.painterResource
import com.zhushenwudi.lovelivemusicplayer.AppContainer import com.zhushenwudi.lovelivemusicplayer.AppContainer
import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
import com.zhushenwudi.lovelivemusicplayer.generated.resources.launch_background
import com.zhushenwudi.lovelivemusicplayer.generated.resources.launch_background_dark
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.ui.image.LlmpAsyncImage import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
@@ -39,9 +45,9 @@ import kotlin.time.Duration.Companion.seconds
* 2) 若有本地缓存则随机切换(不等待网络) * 2) 若有本地缓存则随机切换(不等待网络)
* 3) 倒计时/跳过 → 主页;离开后后台继续网络同步 * 3) 倒计时/跳过 → 主页;离开后后台继续网络同步
*/ */
@OptIn(ExperimentalResourceApi::class)
@Composable @Composable
fun SplashScreen(onFinished: () -> Unit) { fun SplashScreen(onFinished: () -> Unit) {
val dark = LocalDarkTheme.current
var count by remember { mutableIntStateOf(3) } var count by remember { mutableIntStateOf(3) }
var photoPath by remember { mutableStateOf<String?>(null) } var photoPath by remember { mutableStateOf<String?>(null) }
var finished by remember { mutableStateOf(false) } var finished by remember { mutableStateOf(false) }
@@ -70,20 +76,35 @@ fun SplashScreen(onFinished: () -> Unit) {
goHome() goHome()
} }
Box( BoxWithConstraints(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.White), .background(if (dark) ColorMs.NightPrimary else Color.White),
) { ) {
val landscape = LocalIsLandscape.current val landscape = LocalIsLandscape.current
val model = photoPath ?: Res.getUri("drawable/launch_background.png") if (photoPath == null) {
LlmpAsyncImage( // 独立方形 Logo,横竖屏均居中;不把 app 图标拉伸成全屏图片。
model = model, val logoSize = minOf(maxWidth * 0.7f, maxHeight * 0.55f, 280.dp)
contentDescription = null, Image(
contentScale = if (photoPath != null && !landscape) ContentScale.Crop else ContentScale.Fit, painter = painterResource(
alignment = if (landscape) Alignment.Center else Alignment.TopCenter, if (dark) Res.drawable.launch_background_dark else Res.drawable.launch_background,
modifier = Modifier.fillMaxSize(), ),
) contentDescription = "LLMP",
contentScale = ContentScale.Fit,
modifier = Modifier
.align(Alignment.Center)
.size(logoSize)
.clip(RoundedCornerShape(32.dp)),
)
} else {
LlmpAsyncImage(
model = photoPath,
contentDescription = null,
contentScale = if (landscape) ContentScale.Fit else ContentScale.Crop,
alignment = if (landscape) Alignment.Center else Alignment.TopCenter,
modifier = Modifier.fillMaxSize(),
)
}
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
@@ -0,0 +1,13 @@
package com.zhushenwudi.lovelivemusicplayer.ui.player
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.platform.SystemFont
@OptIn(ExperimentalTextApi::class)
private val EmptyPlayerTitleFont = FontFamily(
SystemFont(identity = "PingFang SC", weight = FontWeight.Bold),
)
internal actual fun emptyPlayerTitleFontFamily(): FontFamily = EmptyPlayerTitleFont
+4
View File
@@ -15,6 +15,7 @@
8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142951DE661E55DC3207327 /* WidgetAppUtils.swift */; }; 8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142951DE661E55DC3207327 /* WidgetAppUtils.swift */; };
88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */; }; 88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */; };
93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */; }; 93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */; };
E305C301A1B24DD0AC001002 /* QrScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E305C301A1B24DD0AC001001 /* QrScannerViewController.swift */; };
A1B2C3D4E5F60718293A4B5C /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; }; A1B2C3D4E5F60718293A4B5C /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; };
A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */; }; A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */; };
B7C8D9E0F1A234567890ABCD /* InAppWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */; }; B7C8D9E0F1A234567890ABCD /* InAppWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */; };
@@ -61,6 +62,7 @@
310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; }; 310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; };
59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIntent.swift; sourceTree = "<group>"; }; 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIntent.swift; sourceTree = "<group>"; };
7B6B3C131D45A3E52B69B531 /* HostBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBridge.swift; sourceTree = "<group>"; }; 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBridge.swift; sourceTree = "<group>"; };
E305C301A1B24DD0AC001001 /* QrScannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrScannerViewController.swift; sourceTree = "<group>"; };
86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
88A292AA7607F14E8C66291E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 88A292AA7607F14E8C66291E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -111,6 +113,7 @@
A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */, A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */,
B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */, B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */,
7B6B3C131D45A3E52B69B531 /* HostBridge.swift */, 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */,
E305C301A1B24DD0AC001001 /* QrScannerViewController.swift */,
D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */, D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */,
D1E2F3A4B5C60718293A4B07 /* iosApp-Bridging-Header.h */, D1E2F3A4B5C60718293A4B07 /* iosApp-Bridging-Header.h */,
1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */, 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */,
@@ -324,6 +327,7 @@
FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */, FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */,
56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */, 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */,
93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */, 93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */,
E305C301A1B24DD0AC001002 /* QrScannerViewController.swift in Sources */,
A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */, A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */,
88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */, 88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */,
8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */, 8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */,
+7
View File
@@ -48,6 +48,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
private func registerHostNotifications() { private func registerHostNotifications() {
NotificationCenter.default.addObserver(
forName: Notification.Name("llmpOpenQrScanner"),
object: nil,
queue: .main
) { notification in
HostBridge.shared.presentQrScanner(from: notification.userInfo)
}
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
forName: Notification.Name("llmpShareRequest"), forName: Notification.Name("llmpShareRequest"),
object: nil, object: nil,
@@ -1,5 +1,17 @@
{ {
"images": [ "images": [
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"size": "1024x1024",
"idiom": "universal",
"platform": "ios",
"filename": "icon-dark-1024.png"
},
{ {
"size": "20x20", "size": "20x20",
"idiom": "iphone", "idiom": "iphone",
@@ -117,6 +129,6 @@
], ],
"info": { "info": {
"version": 1, "version": 1,
"author": "icon.wuruihong.com" "author": "xcode"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+23
View File
@@ -7,6 +7,7 @@ final class HostBridge {
static let shared = HostBridge() static let shared = HostBridge()
private var localNetworkBrowser: NWBrowser? private var localNetworkBrowser: NWBrowser?
private var localNetworkTimeout: DispatchWorkItem? private var localNetworkTimeout: DispatchWorkItem?
private var qrScanner: QrScannerViewController?
/// iOS 没有显式的本地网络权限 API;开始 Bonjour 浏览会在首次进入传输页时触发系统授权。 /// iOS 没有显式的本地网络权限 API;开始 Bonjour 浏览会在首次进入传输页时触发系统授权。
func requestLocalNetworkPermission() { func requestLocalNetworkPermission() {
@@ -139,6 +140,28 @@ final class HostBridge {
root.present(nav, animated: true) root.present(nav, animated: true)
} }
func presentQrScanner(from userInfo: [AnyHashable: Any]?) {
guard let requestId = userInfo?["requestId"] as? String else { return }
let reply: (String) -> Void = { result in
NotificationCenter.default.post(
name: Notification.Name("llmpQrScanResult"),
object: nil,
userInfo: ["requestId": requestId, "result": result]
)
}
guard qrScanner == nil, let root = topViewController(), !root.isBeingDismissed else {
reply("")
return
}
let scanner = QrScannerViewController(labels: userInfo ?? [:]) { [weak self] result in
self?.qrScanner = nil
reply(result)
}
qrScanner = scanner
scanner.modalPresentationStyle = .fullScreen
root.present(scanner, animated: true)
}
private func topViewController() -> UIViewController? { private func topViewController() -> UIViewController? {
let scenes = UIApplication.shared.connectedScenes let scenes = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene } .compactMap { $0 as? UIWindowScene }
+211
View File
@@ -0,0 +1,211 @@
import AVFoundation
import UIKit
/// Native camera scanner shared by song transfer and data sync.
final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
private let labels: [AnyHashable: Any]
private let onResult: (String) -> Void
private let session = AVCaptureSession()
private let sessionQueue = DispatchQueue(label: "llmp.qr-camera")
private lazy var preview = AVCaptureVideoPreviewLayer(session: session)
// Camera configuration and start/stop are serialized on sessionQueue.
private var configured = false
private var finished = false
private var requestedCamera = false
private var foregroundObserver: NSObjectProtocol?
init(labels: [AnyHashable: Any], onResult: @escaping (String) -> Void) {
self.labels = labels
self.onResult = onResult
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
deinit {
if let foregroundObserver { NotificationCenter.default.removeObserver(foregroundObserver) }
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
preview.videoGravity = .resizeAspectFill
view.layer.addSublayer(preview)
let cancel = UIButton(type: .system)
var cancelStyle = UIButton.Configuration.plain()
cancelStyle.title = label("cancel")
cancelStyle.baseForegroundColor = .white
cancelStyle.background.backgroundColor = UIColor.black.withAlphaComponent(0.6)
cancelStyle.background.cornerRadius = 12
cancelStyle.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 20)
cancel.configuration = cancelStyle
cancel.addTarget(self, action: #selector(cancelScan), for: .touchUpInside)
cancel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(cancel)
let hint = UILabel()
hint.text = label("title")
hint.textColor = .white
hint.font = .systemFont(ofSize: 18, weight: .medium)
hint.textAlignment = .center
hint.numberOfLines = 0
hint.backgroundColor = UIColor.black.withAlphaComponent(0.6)
hint.layer.cornerRadius = 12
hint.clipsToBounds = true
hint.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hint)
NSLayoutConstraint.activate([
cancel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20),
cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
hint.centerXAnchor.constraint(equalTo: view.centerXAnchor),
hint.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24),
hint.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24),
hint.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24),
hint.heightAnchor.constraint(greaterThanOrEqualToConstant: 56),
])
foregroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in
guard let self, !self.finished, self.viewIfLoaded?.window != nil else { return }
self.startConfiguredSession()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !requestedCamera else { startConfiguredSession(); return }
requestedCamera = true
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
configureCamera()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
guard let self, !self.finished else { return }
if granted { self.configureCamera() } else { self.showFailure("permission", offerSettings: true) }
}
}
case .denied, .restricted:
showFailure("permission", offerSettings: true)
@unknown default:
showFailure("failed")
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
sessionQueue.async { [session] in
if session.isRunning { session.stopRunning() }
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// Also release the Kotlin request if the host dismisses this screen externally.
if isBeingDismissed && !finished {
finished = true
onResult("")
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
preview.frame = view.bounds
guard let orientation = view.window?.windowScene?.interfaceOrientation,
let connection = preview.connection else { return }
let angle: CGFloat
switch orientation {
case .landscapeLeft: angle = 180
case .landscapeRight: angle = 0
case .portraitUpsideDown: angle = 270
default: angle = 90
}
if connection.isVideoRotationAngleSupported(angle) { connection.videoRotationAngle = angle }
}
private func configureCamera() {
sessionQueue.async { [weak self] in
guard let self else { return }
guard let camera = AVCaptureDevice.default(for: .video) else {
self.reportFailure("unavailable")
return
}
do {
let input = try AVCaptureDeviceInput(device: camera)
let output = AVCaptureMetadataOutput()
self.session.beginConfiguration()
self.session.sessionPreset = .high
guard self.session.canAddInput(input), self.session.canAddOutput(output) else {
self.session.commitConfiguration()
self.reportFailure("failed")
return
}
self.session.addInput(input)
self.session.addOutput(output)
guard output.availableMetadataObjectTypes.contains(.qr) else {
self.session.commitConfiguration()
self.reportFailure("failed")
return
}
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
self.session.commitConfiguration()
self.configured = true
self.session.startRunning()
DispatchQueue.main.async { [weak self] in self?.view.setNeedsLayout() }
} catch {
self.reportFailure("failed")
}
}
}
private func startConfiguredSession() {
guard !finished else { return }
sessionQueue.async { [weak self] in
guard let self, self.configured, !self.session.isRunning else { return }
self.session.startRunning()
}
}
private func reportFailure(_ key: String) {
DispatchQueue.main.async { [weak self] in self?.showFailure(key) }
}
private func showFailure(_ key: String, offerSettings: Bool = false) {
guard !finished, presentedViewController == nil else { return }
let alert = UIAlertController(title: label("title"), message: label(key), preferredStyle: .alert)
alert.addAction(UIAlertAction(title: label("cancel"), style: .cancel) { [weak self] _ in self?.finish("") })
if offerSettings {
alert.addAction(UIAlertAction(title: label("settings"), style: .default) { [weak self] _ in
self?.finish("") {
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
}
})
}
present(alert, animated: true)
}
private func label(_ key: String) -> String { labels[key] as? String ?? "" }
@objc private func cancelScan() { finish("") }
private func finish(_ result: String, completion: (() -> Void)? = nil) {
guard !finished else { return }
finished = true
sessionQueue.async { [session] in
if session.isRunning { session.stopRunning() }
}
dismiss(animated: true) { [onResult] in
onResult(result)
completion?()
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput objects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard !finished,
let code = objects.compactMap({ $0 as? AVMetadataMachineReadableCodeObject }).first,
let value = code.stringValue, !value.isEmpty else { return }
finish(value)
}
}
@@ -1,6 +1,9 @@
package com.zhushenwudi.lovelivemusicplayer.i18n package com.zhushenwudi.lovelivemusicplayer.i18n
internal val DeStrings: Map<String, String> = mapOf( internal val DeStrings: Map<String, String> = mapOf(
"open_drawer" to "Menü öffnen",
"qr_camera_unavailable" to "Kamera nicht verfügbar. Bitte die PC-IP manuell eingeben.",
"qr_scan_failed" to "Scan konnte nicht gestartet werden. Erneut versuchen oder die PC-IP manuell eingeben.",
"music" to "Die Musik", "music" to "Die Musik",
"album" to "Das Album", "album" to "Das Album",
"music_name" to "Der Liedtitel", "music_name" to "Der Liedtitel",
@@ -1,6 +1,9 @@
package com.zhushenwudi.lovelivemusicplayer.i18n package com.zhushenwudi.lovelivemusicplayer.i18n
internal val EnStrings: Map<String, String> = mapOf( internal val EnStrings: Map<String, String> = mapOf(
"open_drawer" to "Open menu",
"qr_camera_unavailable" to "Camera unavailable. Please enter your PC IP manually.",
"qr_scan_failed" to "Could not start scanning. Try again or enter your PC IP manually.",
"music" to "Music", "music" to "Music",
"album" to "Album", "album" to "Album",
"music_name" to "Music name", "music_name" to "Music name",
@@ -1,6 +1,9 @@
package com.zhushenwudi.lovelivemusicplayer.i18n package com.zhushenwudi.lovelivemusicplayer.i18n
internal val JaStrings: Map<String, String> = mapOf( internal val JaStrings: Map<String, String> = mapOf(
"open_drawer" to "メニューを開く",
"qr_camera_unavailable" to "カメラを使用できません。PCのIPアドレスを手動で入力してください。",
"qr_scan_failed" to "スキャンを開始できません。再試行するかPCのIPアドレスを手動で入力してください。",
"music" to "曲", "music" to "曲",
"album" to "アルバム", "album" to "アルバム",
"music_name" to "曲名", "music_name" to "曲名",
@@ -1,6 +1,9 @@
package com.zhushenwudi.lovelivemusicplayer.i18n package com.zhushenwudi.lovelivemusicplayer.i18n
internal val ZhStrings: Map<String, String> = mapOf( internal val ZhStrings: Map<String, String> = mapOf(
"open_drawer" to "打开菜单",
"qr_camera_unavailable" to "当前设备无法使用摄像头,请手动输入电脑 IP",
"qr_scan_failed" to "无法启动扫码,请稍后重试或手动输入电脑 IP",
"music" to "歌曲", "music" to "歌曲",
"album" to "专辑", "album" to "专辑",
"music_name" to "歌名", "music_name" to "歌名",
@@ -3,6 +3,10 @@ package com.zhushenwudi.lovelivemusicplayer.platform
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSURL import platform.Foundation.NSURL
import platform.Foundation.NSURLComponents import platform.Foundation.NSURLComponents
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSOperationQueue
import platform.darwin.NSObjectProtocol
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import platform.UIKit.UIApplication import platform.UIKit.UIApplication
import platform.darwin.dispatch_async import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue import platform.darwin.dispatch_get_main_queue
@@ -10,9 +14,37 @@ import kotlin.system.exitProcess
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
actual object PlatformUi { actual object PlatformUi {
private var qrObserver: NSObjectProtocol? = null
private var qrRequestId = 0L
actual fun openQrScanner(onResult: (String) -> Unit) { actual fun openQrScanner(onResult: (String) -> Unit) {
// iOS 相机扫码由 Swift 承载;common UI 仍支持 IP 粘贴。 // 重复点击只打开一个扫码器;取消/失败也必须回传并清理观察者。
onResult("") if (qrObserver != null) return
val requestId = (++qrRequestId).toString()
val center = NSNotificationCenter.defaultCenter
qrObserver = center.addObserverForName(
"llmpQrScanResult",
`object` = null,
queue = NSOperationQueue.mainQueue,
) { notification ->
if (notification?.userInfo?.get("requestId") != requestId) return@addObserverForName
val result = notification.userInfo?.get("result") as? String ?: ""
qrObserver?.let { center.removeObserver(it) }
qrObserver = null
onResult(result)
}
WidgetDataWriter.post(
"llmpOpenQrScanner",
mapOf(
"requestId" to requestId,
"title" to I18n.t("scan_qr_prompt"),
"cancel" to I18n.t("cancel"),
"permission" to I18n.t("camera_permission"),
"settings" to I18n.t("system_settings"),
"unavailable" to I18n.t("qr_camera_unavailable"),
"failed" to I18n.t("qr_scan_failed"),
),
)
} }
actual fun openWebView(url: String, title: String?) { actual fun openWebView(url: String, title: String?) {