diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png index 0883fa7..e674995 100644 Binary files a/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png index 07be720..e674995 100644 Binary files a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png index e691ace..553869c 100644 Binary files a/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png index f1697c0..553869c 100644 Binary files a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png index 2e2403e..44c57e4 100644 Binary files a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png index cf38496..44c57e4 100644 Binary files a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png index 386df63..e9588a2 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 0c0d625..e9588a2 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png index ddd051f..eaabd25 100644 Binary files a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index abc1a5b..eaabd25 100644 Binary files a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/EmptyPlayerTitleFont.android.kt b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/EmptyPlayerTitleFont.android.kt new file mode 100644 index 0000000..8e8c58b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/EmptyPlayerTitleFont.android.kt @@ -0,0 +1,5 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.player + +import androidx.compose.ui.text.font.FontFamily + +internal actual fun emptyPlayerTitleFontFamily(): FontFamily = FontFamily.Default diff --git a/composeApp/src/commonMain/composeResources/drawable/launch_background.png b/composeApp/src/commonMain/composeResources/drawable/launch_background.png index 91f37bb..603cb62 100644 Binary files a/composeApp/src/commonMain/composeResources/drawable/launch_background.png and b/composeApp/src/commonMain/composeResources/drawable/launch_background.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/launch_background_dark.png b/composeApp/src/commonMain/composeResources/drawable/launch_background_dark.png new file mode 100644 index 0000000..8bcd5ec Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/launch_background_dark.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/llmp_logo_dark.png b/composeApp/src/commonMain/composeResources/drawable/llmp_logo_dark.png new file mode 100644 index 0000000..14f7c1d Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/llmp_logo_dark.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/llmp_logo_light.png b/composeApp/src/commonMain/composeResources/drawable/llmp_logo_light.png new file mode 100644 index 0000000..f198b3e Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/llmp_logo_light.png differ diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/MainShell.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/MainShell.kt index 65c5d68..22fabd9 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/MainShell.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/MainShell.kt @@ -87,16 +87,15 @@ import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme import com.zhushenwudi.lovelivemusicplayer.ui.components.AddToMenuDialog import com.zhushenwudi.lovelivemusicplayer.ui.components.AppDrawer import com.zhushenwudi.lovelivemusicplayer.ui.components.BatchActionBar +import com.zhushenwudi.lovelivemusicplayer.ui.components.DrawerLogoButton import com.zhushenwudi.lovelivemusicplayer.ui.components.LandscapeLibraryHeader import com.zhushenwudi.lovelivemusicplayer.ui.components.LibrarySelectMode import com.zhushenwudi.lovelivemusicplayer.ui.components.LlmpBottomBar import com.zhushenwudi.lovelivemusicplayer.ui.components.LoadingOverlay -import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicGroupLogoButton import com.zhushenwudi.lovelivemusicplayer.ui.components.SongLibraryTopBar import com.zhushenwudi.lovelivemusicplayer.ui.components.TopLibraryTabs import com.zhushenwudi.lovelivemusicplayer.ui.components.TwoButtonDialog 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.LocalMiniPlayerHaze import com.zhushenwudi.lovelivemusicplayer.ui.player.MiniPlayerBar @@ -761,7 +760,6 @@ fun MainShell( } } }, - group = currentGroup, onOpenDrawer = if (batchLock || detailOpen) { null } else { @@ -788,26 +786,13 @@ fun MainShell( }, modifier = Modifier.weight(1f), ) - // 顶部团组 Logo 按钮,随 currentGroup 变化。 - NeumorphicGroupLogoButton( - group = currentGroup, + DrawerLogoButton( onClick = if (batchLock || detailOpen) { null } else { { scope.launch { drawerState.open() } } }, - size = 36.dp, - background = if (currentGroup.isDefaultGroupLogo()) { - Color(0x4DFFAE00) - } else { - shellBg - }, - contentPadding = if (currentGroup.isDefaultGroupLogo()) { - 3.dp - } else { - 0.dp - }, - hasShadow = true, + background = shellBg, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AppDrawer.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AppDrawer.kt index 269d92e..7e6da5b 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AppDrawer.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AppDrawer.kt @@ -1,5 +1,6 @@ package com.zhushenwudi.lovelivemusicplayer.ui.components +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -36,6 +37,9 @@ import coil3.compose.AsyncImage import org.jetbrains.compose.resources.ExperimentalResourceApi import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey 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.navigation.Routes import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs @@ -61,7 +65,7 @@ enum class DrawerAction { /** 与 Flutter `DrawerLayout` 对齐的抽屉尺寸。 */ private object DrawerLayout { val logoSlotHeight = 130.dp - val defaultLogoSize = 165.dp + val defaultLogoSize = 120.dp val groupLogoSize = 130.dp val logoToTitleGap = 5.dp val titleFontSize = 17.3.sp @@ -187,10 +191,21 @@ fun AppDrawer( .fillMaxWidth(), contentAlignment = Alignment.Center, ) { - GroupLogoImage( - group = currentGroup, - modifier = Modifier.size(topLogoSize), - ) + if (currentGroup.isDefaultGroupLogo()) { + Image( + 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)) Text( diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DrawerLogoButton.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DrawerLogoButton.kt new file mode 100644 index 0000000..355e71f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DrawerLogoButton.kt @@ -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"), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/NeumorphicButton.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/NeumorphicButton.kt index 40a930d..2ea02ae 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/NeumorphicButton.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/NeumorphicButton.kt @@ -136,6 +136,7 @@ fun NeumorphicDrawableButton( hasShadow: Boolean = true, contentPadding: Dp = 0.dp, contentScale: ContentScale = ContentScale.Crop, + contentDescription: String? = null, ) { val shape = if (radius >= size / 2) CircleShape else RoundedCornerShape(radius) NeumorphicSurface( @@ -151,7 +152,7 @@ fun NeumorphicDrawableButton( key(drawable) { androidx.compose.foundation.Image( painter = painterResource(drawable), - contentDescription = null, + contentDescription = contentDescription, contentScale = contentScale, modifier = Modifier .fillMaxSize() diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/PairingPageLayout.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/PairingPageLayout.kt new file mode 100644 index 0000000..e8c9c01 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/PairingPageLayout.kt @@ -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)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/TopLibraryTabs.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/TopLibraryTabs.kt index 698aac0..99192e1 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/TopLibraryTabs.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/TopLibraryTabs.kt @@ -38,7 +38,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey import com.zhushenwudi.lovelivemusicplayer.i18n.tr import com.zhushenwudi.lovelivemusicplayer.navigation.LibraryTab import com.zhushenwudi.lovelivemusicplayer.navigation.TopSection @@ -114,7 +113,6 @@ fun LandscapeLibraryHeader( interactionEnabled: Boolean, query: String, onQueryChange: (String) -> Unit, - group: GroupKey, onOpenDrawer: (() -> Unit)?, shellBg: Color, showSearch: Boolean, @@ -219,13 +217,9 @@ fun LandscapeLibraryHeader( } } Spacer(Modifier.width(12.dp)) - NeumorphicGroupLogoButton( - group = group, + DrawerLogoButton( onClick = onOpenDrawer, - size = 36.dp, - background = if (group.isDefaultGroupLogo()) Color(0x4DFFAE00) else shellBg, - contentPadding = if (group.isDefaultGroupLogo()) 3.dp else 0.dp, - hasShadow = true, + background = shellBg, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/EmptyPlayerTitleFont.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/EmptyPlayerTitleFont.kt new file mode 100644 index 0000000..885c21f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/EmptyPlayerTitleFont.kt @@ -0,0 +1,6 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.player + +import androidx.compose.ui.text.font.FontFamily + +/** 空状态标题字体;iOS 明确选择中文字族,避免粗体逐字回退造成视觉深浅不一。 */ +internal expect fun emptyPlayerTitleFontFamily(): FontFamily diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/MiniPlayerBar.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/MiniPlayerBar.kt index a176166..59573a5 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/MiniPlayerBar.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/MiniPlayerBar.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity 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.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign @@ -166,6 +167,7 @@ fun MiniPlayerBar( MiniPlayerTitle( text = title, color = titleColor, + fontFamily = if (current == null) emptyPlayerTitleFontFamily() else null, modifier = Modifier .weight(1f) .then( @@ -248,10 +250,11 @@ private const val MiniTitleMarqueeHoldMs = 2_000 private fun MiniPlayerTitle( text: String, color: Color, + fontFamily: FontFamily?, modifier: Modifier = Modifier, ) { val textMeasurer = rememberTextMeasurer() - val style = MiniTitleStyle.copy(color = color) + val style = MiniTitleStyle.copy(color = color, fontFamily = fontFamily) BoxWithConstraints( modifier = modifier.clipToBounds(), contentAlignment = Alignment.CenterStart, diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt index 5992792..0ddab70 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.withLink import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers 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.NeumorphicIconButton 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.ResetDataDialog 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) } val dark = LocalDarkTheme.current 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( containerColor = bg, topBar = { AppHeader(title = tr("scan_pair"), onBack = onBack) }, ) { padding -> - Column( - Modifier - .padding(padding) - .fillMaxSize() - .imePadding() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Box( - Modifier - .size(250.dp) - .clip(RoundedCornerShape(12.dp)) - .background(if (dark) ColorMs.Dark1E2328 else ColorMs.E7F2FF) - .clickable { - com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi.openQrScanner { result -> - val parsed = parseHost(result) - if (parsed.isNotBlank()) { - 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)) + PairingPageLayout( + modifier = Modifier.padding(padding).consumeWindowInsets(padding).imePadding(), + illustration = { size -> + Box( + Modifier + .size(minOf(size, 250.dp)) + .clip(RoundedCornerShape(12.dp)) + .background(if (dark) ColorMs.Dark1E2328 else ColorMs.E7F2FF) + .clickable { scan() }, + 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( value = host, onValueChange = { host = it.trim() }, @@ -447,10 +449,7 @@ fun ScanScreen(onBack: () -> Unit, onConnectedHost: (String) -> Unit) { PinkFuncButton( title = tr("confirm_pair"), icon = SvgAsset("drawable/sync_icon_scan_qr.svg"), - onClick = { - AppContainer.settings.lastPcHost = host - onConnectedHost(host) - }, + onClick = { connect(host) }, ) } } @@ -488,75 +487,69 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) { .background(bg), ) { AppHeader(title = tr("music_trans"), onBack = { requestBack() }) - Column( - Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, + PairingPageLayout( + modifier = Modifier.weight(1f), + illustration = { size -> + if (connected || state.completed) { + TransferCoverBlock( + 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) { - Spacer(Modifier.height(35.dp)) - TransferCoverBlock( - coverUrl = state.coverUrl, - coverKey = state.coverKey, - song = state.current, - artist = state.artist, - onIdleChange = { coverAnimationsIdle = it }, - onDisplayedSongChange = { song, artist -> - displayedSong = song - displayedArtist = artist - }, - ) - Spacer(Modifier.height(20.dp)) - Column( - Modifier.height(60.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - if (displayedSong.isNotBlank()) { - Text( - displayedSong, - color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = hSp(15f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - displayedArtist, - color = ColorMs.Gray999, - fontSize = hSp(12f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (connected || state.completed) { + Column( + Modifier.height(60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (displayedSong.isNotBlank()) { + Text( + displayedSong, + color = if (dark) ColorMs.White else ColorMs.Text333, + fontSize = hSp(15f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + displayedArtist, + color = ColorMs.Gray999, + fontSize = hSp(12f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(Modifier.height(24.dp)) + TransferProgressRing( + progress = state.progress, + total = state.total, + downloading = state.total > 0, + ) + } else { + PairTipsBlock() + Spacer(Modifier.height(24.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)) } } - 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) { @@ -588,6 +581,7 @@ private fun TransferCoverBlock( coverKey: String, song: String, artist: String, + size: Dp, onIdleChange: (Boolean) -> Unit, onDisplayedSongChange: (String, String) -> Unit, ) { @@ -644,11 +638,12 @@ private fun TransferCoverBlock( userScrollEnabled = false, modifier = Modifier .fillMaxWidth() - .height(343.dp), + .height(size), ) { page -> val cover = covers[page] TransferCoverCircle( cover = cover, + size = minOf(CoverCarouselSize, size * 0.8f), onReady = { if (cover.pageId !in readyCoverPages) { readyCoverPages.add(cover.pageId) @@ -670,13 +665,14 @@ private data class TransferCoverPage( @Composable private fun TransferCoverCircle( cover: TransferCoverPage, + size: Dp, onReady: () -> Unit, ) { var loadFailed by remember(cover.url) { mutableStateOf(false) } val latestOnReady by rememberUpdatedState(onReady) Box( Modifier - .size(CoverCarouselSize) + .size(size) .clip(CircleShape), contentAlignment = Alignment.Center, ) { @@ -698,7 +694,7 @@ private fun TransferCoverCircle( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), stableCacheKey = cover.key, - decodeSize = CoverCarouselSize, + decodeSize = size, onSuccess = { latestOnReady() }, onError = { loadFailed = true @@ -781,27 +777,23 @@ fun DataSyncScreen(onBack: () -> Unit, onOpenScan: () -> Unit) { containerColor = bg, topBar = { AppHeader(title = tr("data_sync"), onBack = onBack) }, ) { padding -> - Column( - Modifier - .padding(padding) - .fillMaxSize() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(15.dp)) - Box(Modifier.size(343.dp), contentAlignment = Alignment.Center) { - if (connected) { - WaterRipplePlaceholder() - } else { - SvgIcon( - asset = SvgAsset("drawable/sync_icon_data_sync.svg"), - size = 300.dp, - ) + PairingPageLayout( + modifier = Modifier.padding(padding).consumeWindowInsets(padding), + illustration = { size -> + Box(Modifier.size(size), contentAlignment = Alignment.Center) { + if (connected) { + WaterRipplePlaceholder(size) + } else { + SvgIcon( + asset = SvgAsset("drawable/sync_icon_data_sync.svg"), + size = minOf(size, 300.dp), + ) + } } - } - Spacer(Modifier.height(20.dp)) + }, + ) { PairTipsBlock() - Spacer(Modifier.height(60.dp)) + Spacer(Modifier.height(24.dp)) if (connected) { PinkFuncButton( title = tr("phone2pc"), @@ -859,19 +851,19 @@ fun DataSyncScreen(onBack: () -> Unit, onOpenScan: () -> Unit) { } @Composable -private fun WaterRipplePlaceholder() { - Box(Modifier.size(343.dp), contentAlignment = Alignment.Center) { +private fun WaterRipplePlaceholder(size: Dp) { + Box(Modifier.size(size), contentAlignment = Alignment.Center) { listOf(0.35f, 0.55f, 0.75f, 0.95f).forEach { scale -> Box( Modifier - .size((343 * scale).dp) + .size(size * scale) .clip(CircleShape) .background(ColorMs.PinkF940A7.copy(alpha = 0.08f + (1f - scale) * 0.08f)), ) } SvgIcon( asset = SvgAsset("drawable/sync_icon_data_sync.svg"), - size = 96.dp, + size = size * (96f / 343f), tint = ColorMs.PinkF940A7, ) } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/SplashScreen.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/SplashScreen.kt index 8e03f32..6ec07c9 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/SplashScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/SplashScreen.kt @@ -1,11 +1,14 @@ package com.zhushenwudi.lovelivemusicplayer.ui.screens +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text 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.sp 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.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.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape import androidx.compose.foundation.layout.navigationBarsPadding @@ -39,9 +45,9 @@ import kotlin.time.Duration.Companion.seconds * 2) 若有本地缓存则随机切换(不等待网络) * 3) 倒计时/跳过 → 主页;离开后后台继续网络同步 */ -@OptIn(ExperimentalResourceApi::class) @Composable fun SplashScreen(onFinished: () -> Unit) { + val dark = LocalDarkTheme.current var count by remember { mutableIntStateOf(3) } var photoPath by remember { mutableStateOf(null) } var finished by remember { mutableStateOf(false) } @@ -70,20 +76,35 @@ fun SplashScreen(onFinished: () -> Unit) { goHome() } - Box( + BoxWithConstraints( Modifier .fillMaxSize() - .background(Color.White), + .background(if (dark) ColorMs.NightPrimary else Color.White), ) { val landscape = LocalIsLandscape.current - val model = photoPath ?: Res.getUri("drawable/launch_background.png") - LlmpAsyncImage( - model = model, - contentDescription = null, - contentScale = if (photoPath != null && !landscape) ContentScale.Crop else ContentScale.Fit, - alignment = if (landscape) Alignment.Center else Alignment.TopCenter, - modifier = Modifier.fillMaxSize(), - ) + if (photoPath == null) { + // 独立方形 Logo,横竖屏均居中;不把 app 图标拉伸成全屏图片。 + val logoSize = minOf(maxWidth * 0.7f, maxHeight * 0.55f, 280.dp) + Image( + painter = painterResource( + if (dark) Res.drawable.launch_background_dark else Res.drawable.launch_background, + ), + 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( modifier = Modifier .align(Alignment.BottomEnd) diff --git a/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/EmptyPlayerTitleFont.ios.kt b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/EmptyPlayerTitleFont.ios.kt new file mode 100644 index 0000000..9a54474 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/EmptyPlayerTitleFont.ios.kt @@ -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 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 61bbeb4..f05d80a 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142951DE661E55DC3207327 /* WidgetAppUtils.swift */; }; 88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1285E8A7B7E7359EA11B9591 /* SceneDelegate.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 */; }; A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2016716493FA6F4B6E30EB17 /* PipScreenManager.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 = ""; }; 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIntent.swift; sourceTree = ""; }; 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBridge.swift; sourceTree = ""; }; + E305C301A1B24DD0AC001001 /* QrScannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrScannerViewController.swift; sourceTree = ""; }; 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 = ""; }; 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -111,6 +113,7 @@ A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */, B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */, 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */, + E305C301A1B24DD0AC001001 /* QrScannerViewController.swift */, D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */, D1E2F3A4B5C60718293A4B07 /* iosApp-Bridging-Header.h */, 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */, @@ -324,6 +327,7 @@ FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */, 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */, 93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */, + E305C301A1B24DD0AC001002 /* QrScannerViewController.swift in Sources */, A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */, 88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */, 8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */, diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 0f432a4..247d338 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -48,6 +48,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } 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( forName: Notification.Name("llmpShareRequest"), object: nil, diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json index 728e6c3..bccc29d 100644 --- a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,5 +1,17 @@ { "images": [ + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "size": "1024x1024", + "idiom": "universal", + "platform": "ios", + "filename": "icon-dark-1024.png" + }, { "size": "20x20", "idiom": "iphone", @@ -117,6 +129,6 @@ ], "info": { "version": 1, - "author": "icon.wuruihong.com" + "author": "xcode" } -} \ No newline at end of file +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png index 2ae12b8..603cb62 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20-ipad.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20-ipad.png index ce8da9d..6b52929 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20-ipad.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20-ipad.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png index 5ba5f3e..e28b7c5 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png index 5ba5f3e..e28b7c5 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png index f9fae9a..53d758e 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29-ipad.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29-ipad.png index e3e0145..2f8b737 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29-ipad.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29-ipad.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29.png index e3e0145..2f8b737 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png index 31c4767..df1d6d4 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png index 31c4767..df1d6d4 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png index 1153ccd..987c285 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40.png index 5ba5f3e..e28b7c5 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png index 3038a23..81013be 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png index 84c3300..1235afd 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png index 84c3300..1235afd 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png index 4ea6e75..135c862 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76.png index 36795cf..434fe36 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png index bbb71aa..fb45079 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png index a68880e..5a0ea2c 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-dark-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-dark-1024.png new file mode 100644 index 0000000..8bcd5ec Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-dark-1024.png differ diff --git a/iosApp/iosApp/HostBridge.swift b/iosApp/iosApp/HostBridge.swift index dde118c..c330d28 100644 --- a/iosApp/iosApp/HostBridge.swift +++ b/iosApp/iosApp/HostBridge.swift @@ -7,6 +7,7 @@ final class HostBridge { static let shared = HostBridge() private var localNetworkBrowser: NWBrowser? private var localNetworkTimeout: DispatchWorkItem? + private var qrScanner: QrScannerViewController? /// iOS 没有显式的本地网络权限 API;开始 Bonjour 浏览会在首次进入传输页时触发系统授权。 func requestLocalNetworkPermission() { @@ -139,6 +140,28 @@ final class HostBridge { 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? { let scenes = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } diff --git a/iosApp/iosApp/QrScannerViewController.swift b/iosApp/iosApp/QrScannerViewController.swift new file mode 100644 index 0000000..134ffd2 --- /dev/null +++ b/iosApp/iosApp/QrScannerViewController.swift @@ -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) + } +} diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/DeStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/DeStrings.kt index e7f328b..8d137ac 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/DeStrings.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/DeStrings.kt @@ -1,6 +1,9 @@ package com.zhushenwudi.lovelivemusicplayer.i18n internal val DeStrings: Map = 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", "album" to "Das Album", "music_name" to "Der Liedtitel", diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/EnStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/EnStrings.kt index d7624ed..9df499c 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/EnStrings.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/EnStrings.kt @@ -1,6 +1,9 @@ package com.zhushenwudi.lovelivemusicplayer.i18n internal val EnStrings: Map = 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", "album" to "Album", "music_name" to "Music name", diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/JaStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/JaStrings.kt index 2eeaafa..a5f8e77 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/JaStrings.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/JaStrings.kt @@ -1,6 +1,9 @@ package com.zhushenwudi.lovelivemusicplayer.i18n internal val JaStrings: Map = mapOf( + "open_drawer" to "メニューを開く", + "qr_camera_unavailable" to "カメラを使用できません。PCのIPアドレスを手動で入力してください。", + "qr_scan_failed" to "スキャンを開始できません。再試行するかPCのIPアドレスを手動で入力してください。", "music" to "曲", "album" to "アルバム", "music_name" to "曲名", diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ZhStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ZhStrings.kt index 9682bd9..4794e6e 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ZhStrings.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ZhStrings.kt @@ -1,6 +1,9 @@ package com.zhushenwudi.lovelivemusicplayer.i18n internal val ZhStrings: Map = mapOf( + "open_drawer" to "打开菜单", + "qr_camera_unavailable" to "当前设备无法使用摄像头,请手动输入电脑 IP", + "qr_scan_failed" to "无法启动扫码,请稍后重试或手动输入电脑 IP", "music" to "歌曲", "album" to "专辑", "music_name" to "歌名", diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.ios.kt index 93b82bf..ba6f480 100644 --- a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.ios.kt +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.ios.kt @@ -3,6 +3,10 @@ package com.zhushenwudi.lovelivemusicplayer.platform import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSURL 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.darwin.dispatch_async import platform.darwin.dispatch_get_main_queue @@ -10,9 +14,37 @@ import kotlin.system.exitProcess @OptIn(ExperimentalForeignApi::class) actual object PlatformUi { + private var qrObserver: NSObjectProtocol? = null + private var qrRequestId = 0L + 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?) {