Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa61bcca38 | ||
|
|
7b856f574e |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 58 KiB |
|
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
|
||||||
|
Before Width: | Height: | Size: 724 KiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 113 KiB |
|
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,11 +191,22 @@ fun AppDrawer(
|
|||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
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(
|
GroupLogoImage(
|
||||||
group = currentGroup,
|
group = currentGroup,
|
||||||
modifier = Modifier.size(topLogoSize),
|
modifier = Modifier.size(topLogoSize),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(DrawerLayout.logoToTitleGap))
|
Spacer(Modifier.height(DrawerLayout.logoToTitleGap))
|
||||||
Text(
|
Text(
|
||||||
text = "LoveLiveMusicPlayer",
|
text = "LoveLiveMusicPlayer",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import com.zhushenwudi.lovelivemusicplayer.domain.Music
|
import com.zhushenwudi.lovelivemusicplayer.domain.Music
|
||||||
@@ -88,12 +89,13 @@ fun DriveModeCarousel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 驾驶模式与歌曲快传共用的封面 Pager,保证两处运动效果完全一致。 */
|
/** 共用封面 Pager;传输页可关闭驾驶模式的纵向偏移,让封面中心水平对齐。 */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CoverCarouselPager(
|
internal fun CoverCarouselPager(
|
||||||
state: PagerState,
|
state: PagerState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
userScrollEnabled: Boolean = true,
|
userScrollEnabled: Boolean = true,
|
||||||
|
verticalOffset: Dp = (-40).dp,
|
||||||
pageContent: @Composable (Int) -> Unit,
|
pageContent: @Composable (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||||
@@ -124,7 +126,7 @@ internal fun CoverCarouselPager(
|
|||||||
scaleX = scale
|
scaleX = scale
|
||||||
scaleY = scale
|
scaleY = scale
|
||||||
this.alpha = alpha
|
this.alpha = alpha
|
||||||
translationY = scale * -40.dp.toPx()
|
translationY = scale * verticalOffset.toPx()
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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,85 @@
|
|||||||
|
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,
|
||||||
|
landscapeHeader: (@Composable () -> Unit)? = null,
|
||||||
|
content: @Composable ColumnScope.(isSplitLayout: Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val landscape = LocalIsLandscape.current
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier.fillMaxSize().windowInsetsPadding(
|
||||||
|
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
if (landscape && maxWidth >= 560.dp) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
landscapeHeader?.invoke()
|
||||||
|
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
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(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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(false)
|
||||||
|
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
|
||||||
@@ -78,6 +80,8 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import com.zhushenwudi.lovelivemusicplayer.AppContainer
|
import com.zhushenwudi.lovelivemusicplayer.AppContainer
|
||||||
import com.zhushenwudi.lovelivemusicplayer.Const
|
import com.zhushenwudi.lovelivemusicplayer.Const
|
||||||
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 com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo
|
import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo
|
||||||
import com.zhushenwudi.lovelivemusicplayer.lyric.LoadedLyric
|
import com.zhushenwudi.lovelivemusicplayer.lyric.LoadedLyric
|
||||||
import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser
|
import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser
|
||||||
@@ -99,6 +103,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,35 +400,33 @@ 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
|
||||||
Scaffold(
|
var active by remember { mutableStateOf(true) }
|
||||||
containerColor = bg,
|
DisposableEffect(Unit) { onDispose { active = false } }
|
||||||
topBar = { AppHeader(title = tr("scan_pair"), onBack = onBack) },
|
fun connect(raw: String) {
|
||||||
) { padding ->
|
val parsed = parseHost(raw)
|
||||||
Column(
|
if (parsed.isBlank()) return
|
||||||
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
|
host = parsed
|
||||||
AppContainer.settings.lastPcHost = parsed
|
AppContainer.settings.lastPcHost = parsed
|
||||||
onConnectedHost(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 ->
|
||||||
|
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,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
@@ -436,7 +439,8 @@ fun ScanScreen(onBack: () -> Unit, onConnectedHost: (String) -> Unit) {
|
|||||||
Text(tr("tap_to_scan"), color = ColorMs.Gray999, fontSize = hSp(13f))
|
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 +451,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,65 +489,48 @@ 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()
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
if (connected || state.completed) {
|
if (connected || state.completed) {
|
||||||
Spacer(Modifier.height(35.dp))
|
|
||||||
TransferCoverBlock(
|
TransferCoverBlock(
|
||||||
coverUrl = state.coverUrl,
|
coverUrl = state.coverUrl,
|
||||||
coverKey = state.coverKey,
|
coverKey = state.coverKey,
|
||||||
song = state.current,
|
song = state.current,
|
||||||
artist = state.artist,
|
artist = state.artist,
|
||||||
|
size = size,
|
||||||
onIdleChange = { coverAnimationsIdle = it },
|
onIdleChange = { coverAnimationsIdle = it },
|
||||||
onDisplayedSongChange = { song, artist ->
|
onDisplayedSongChange = { song, artist ->
|
||||||
displayedSong = song
|
displayedSong = song
|
||||||
displayedArtist = artist
|
displayedArtist = artist
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(20.dp))
|
} else {
|
||||||
Column(
|
TransferDefaultLogo(
|
||||||
Modifier.height(60.dp),
|
Modifier.size(minOf(size, 300.dp)).clip(RoundedCornerShape(24.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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
landscapeHeader = {
|
||||||
|
if (connected || state.completed) {
|
||||||
|
TransferSongInfo(displayedSong, displayedArtist)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { isSplitLayout ->
|
||||||
|
if (connected || state.completed) {
|
||||||
|
if (!isSplitLayout) {
|
||||||
|
TransferSongInfo(displayedSong, displayedArtist)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(40.dp))
|
|
||||||
TransferProgressRing(
|
TransferProgressRing(
|
||||||
progress = state.progress,
|
progress = state.progress,
|
||||||
total = state.total,
|
total = state.total,
|
||||||
downloading = state.total > 0,
|
downloading = state.total > 0,
|
||||||
)
|
)
|
||||||
} else {
|
} 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()
|
PairTipsBlock()
|
||||||
Spacer(Modifier.height(85.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
PinkFuncButton(
|
PinkFuncButton(
|
||||||
title = tr("device_pair"),
|
title = tr("device_pair"),
|
||||||
icon = SvgAsset("drawable/sync_icon_scan_qr.svg"),
|
icon = SvgAsset("drawable/sync_icon_scan_qr.svg"),
|
||||||
@@ -573,6 +557,44 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TransferSongInfo(song: String, artist: String) {
|
||||||
|
val dark = LocalDarkTheme.current
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().height(60.dp).padding(horizontal = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
if (song.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
song,
|
||||||
|
color = if (dark) ColorMs.White else ColorMs.Text333,
|
||||||
|
fontSize = hSp(15f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
artist,
|
||||||
|
color = ColorMs.Gray999,
|
||||||
|
fontSize = hSp(12f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TransferDefaultLogo(modifier: Modifier = Modifier) {
|
||||||
|
val dark = LocalDarkTheme.current
|
||||||
|
Image(
|
||||||
|
painter = painterResource(if (dark) Res.drawable.llmp_logo_dark else Res.drawable.llmp_logo_light),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun KeepScreenOn() {
|
private fun KeepScreenOn() {
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
@@ -588,6 +610,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,
|
||||||
) {
|
) {
|
||||||
@@ -642,13 +665,15 @@ private fun TransferCoverBlock(
|
|||||||
CoverCarouselPager(
|
CoverCarouselPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
userScrollEnabled = false,
|
userScrollEnabled = false,
|
||||||
|
verticalOffset = 0.dp,
|
||||||
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,24 +695,22 @@ 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,
|
||||||
) {
|
) {
|
||||||
// 默认图始终垫在底层,即使图片请求重建或超时也不会露出白色空帧。
|
// 默认图始终垫在底层,即使图片请求重建或超时也不会露出白色空帧。
|
||||||
Image(
|
TransferDefaultLogo(
|
||||||
painter = painterResource(Res.drawable.logo_logo),
|
Modifier
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(36.dp),
|
.padding(size * 0.15f),
|
||||||
)
|
)
|
||||||
if (cover.url.isBlank() || loadFailed) {
|
if (cover.url.isBlank() || loadFailed) {
|
||||||
LaunchedEffect(cover.key, loadFailed) { latestOnReady() }
|
LaunchedEffect(cover.key, loadFailed) { latestOnReady() }
|
||||||
@@ -698,7 +721,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 +804,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()),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Spacer(Modifier.height(15.dp))
|
|
||||||
Box(Modifier.size(343.dp), contentAlignment = Alignment.Center) {
|
|
||||||
if (connected) {
|
if (connected) {
|
||||||
WaterRipplePlaceholder()
|
WaterRipplePlaceholder(size)
|
||||||
} else {
|
} else {
|
||||||
SvgIcon(
|
SvgIcon(
|
||||||
asset = SvgAsset("drawable/sync_icon_data_sync.svg"),
|
asset = SvgAsset("drawable/sync_icon_data_sync.svg"),
|
||||||
size = 300.dp,
|
size = minOf(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 +878,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) {
|
||||||
|
// 独立方形 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(
|
LlmpAsyncImage(
|
||||||
model = model,
|
model = photoPath,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = if (photoPath != null && !landscape) ContentScale.Crop else ContentScale.Fit,
|
contentScale = if (landscape) ContentScale.Fit else ContentScale.Crop,
|
||||||
alignment = if (landscape) Alignment.Center else Alignment.TopCenter,
|
alignment = if (landscape) Alignment.Center else Alignment.TopCenter,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 */,
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 701 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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 }
|
||||||
|
|||||||
@@ -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?) {
|
||||||
|
|||||||